mcp-google-ads 1.7.0 → 1.8.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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "95e7b7c",
3
- "builtAt": "2026-07-09T13:47:57.357Z",
2
+ "sha": "b3b7dab",
3
+ "builtAt": "2026-07-20T18:35:03.765Z",
4
4
  "embeddedSecrets": true
5
5
  }
package/dist/index.d.ts CHANGED
@@ -144,6 +144,48 @@ export declare class GoogleAdsManager {
144
144
  ad_id: string | undefined;
145
145
  updated_fields: string[];
146
146
  }>;
147
+ /**
148
+ * Add/replace YouTube videos on an existing video responsive ad (VIDEO
149
+ * channel). Campaign-level VIDEO mutates are MUTATE_NOT_ALLOWED, but the
150
+ * ad-level `video_responsive_ad.videos` update via the `ads` resource is
151
+ * legal and edits in place (same ad ID) — verified live 2026-07-18.
152
+ */
153
+ updateVideoAdVideos(customerId: string, input: {
154
+ ad_id: string;
155
+ videos: string[];
156
+ mode?: "append" | "replace";
157
+ skip_visibility_check?: boolean;
158
+ }): Promise<{
159
+ success: boolean;
160
+ unchanged: boolean;
161
+ ad_id: string;
162
+ ad_resource_name: string;
163
+ mode: "replace" | "append";
164
+ videos_before: number;
165
+ videos_after: number;
166
+ added_assets: string[];
167
+ skipped_existing: string[];
168
+ created_assets: string[];
169
+ video_titles: {
170
+ [k: string]: string | null;
171
+ };
172
+ message: string;
173
+ } | {
174
+ success: boolean;
175
+ unchanged: boolean;
176
+ ad_id: string;
177
+ ad_resource_name: string;
178
+ mode: "replace" | "append";
179
+ videos_before: number;
180
+ videos_after: number;
181
+ added_assets: string[];
182
+ skipped_existing: string[];
183
+ created_assets: string[];
184
+ video_titles: {
185
+ [k: string]: string | null;
186
+ };
187
+ message?: undefined;
188
+ }>;
147
189
  createKeywords(customerId: string, keywords: {
148
190
  ad_group_id: string;
149
191
  keywords: Array<{
package/dist/index.js CHANGED
@@ -47,6 +47,12 @@ import {
47
47
  import {
48
48
  buildAdGroupCreatePayload
49
49
  } from "./adGroupBuilder.js";
50
+ import {
51
+ buildVideoAdUpdateResource,
52
+ checkYoutubeVisibility,
53
+ planVideoUpdate,
54
+ validateYoutubeVideoInputs
55
+ } from "./videoAdVideos.js";
50
56
  import {
51
57
  prepareImageForUpload
52
58
  } from "./imageAsset.js";
@@ -949,7 +955,9 @@ class GoogleAdsManager {
949
955
  const adId = resourceName;
950
956
  const adRows = await withResilience(
951
957
  () => customer.query(
952
- `SELECT ad_group_ad.resource_name FROM ad_group_ad WHERE ad_group_ad.id = ${sanitizeNumericId(
958
+ // ad_group_ad has no queryable `.id`; the ad's numeric ID lives at
959
+ // ad_group_ad.ad.id. (Using ad_group_ad.id fails with query_error 32.)
960
+ `SELECT ad_group_ad.resource_name FROM ad_group_ad WHERE ad_group_ad.ad.id = ${sanitizeNumericId(
953
961
  adId
954
962
  )}`
955
963
  ),
@@ -1027,6 +1035,150 @@ class GoogleAdsManager {
1027
1035
  updated_fields: Object.keys(input).filter((k) => k !== "labels" && input[k])
1028
1036
  };
1029
1037
  }
1038
+ /**
1039
+ * Add/replace YouTube videos on an existing video responsive ad (VIDEO
1040
+ * channel). Campaign-level VIDEO mutates are MUTATE_NOT_ALLOWED, but the
1041
+ * ad-level `video_responsive_ad.videos` update via the `ads` resource is
1042
+ * legal and edits in place (same ad ID) — verified live 2026-07-18.
1043
+ */
1044
+ async updateVideoAdVideos(customerId, input) {
1045
+ const customer = this.getCustomer(customerId);
1046
+ const cleanId = customerId.replace(/-/g, "");
1047
+ const mode = input.mode ?? "append";
1048
+ const parsed = validateYoutubeVideoInputs(input.videos);
1049
+ if (!parsed.valid) {
1050
+ throw new Error("Video input validation failed:\n" + parsed.errors.join("\n"));
1051
+ }
1052
+ let adId = input.ad_id.trim();
1053
+ const rnMatch = adId.match(/^customers\/\d+\/ads\/(\d+)$/);
1054
+ if (rnMatch) adId = rnMatch[1];
1055
+ if (!/^\d+$/.test(adId)) {
1056
+ throw new Error(
1057
+ `ad_id must be a numeric ad ID or customers/X/ads/Y resource name, got "${input.ad_id}"`
1058
+ );
1059
+ }
1060
+ const adRows = await withResilience(
1061
+ () => customer.query(
1062
+ `SELECT ad_group_ad.resource_name, ad_group_ad.ad.type, ad_group_ad.ad.name,
1063
+ ad_group_ad.ad.video_responsive_ad.videos
1064
+ FROM ad_group_ad
1065
+ WHERE ad_group_ad.ad.id = ${sanitizeNumericId(adId)}
1066
+ AND ad_group_ad.status != 'REMOVED'`
1067
+ ),
1068
+ "updateVideoAdVideos.fetchAd"
1069
+ );
1070
+ if (!adRows || adRows.length === 0) {
1071
+ throw new Error(`Ad ${adId} not found (or removed)`);
1072
+ }
1073
+ const adRow = adRows[0];
1074
+ const adType = adRow?.ad_group_ad?.ad?.type;
1075
+ if (adType !== enums.AdType.VIDEO_RESPONSIVE_AD && adType !== "VIDEO_RESPONSIVE_AD") {
1076
+ throw new Error(
1077
+ `Ad ${adId} is not a VIDEO_RESPONSIVE_AD (type=${adType}). This tool only manages videos on video responsive ads.`
1078
+ );
1079
+ }
1080
+ const adGroupAdRN = adRow.ad_group_ad.resource_name;
1081
+ const currentVideos = (adRow.ad_group_ad.ad.video_responsive_ad?.videos ?? []).map((v) => v.asset);
1082
+ const visibility = {};
1083
+ if (!input.skip_visibility_check) {
1084
+ for (const videoId of parsed.ids) {
1085
+ const check = await checkYoutubeVisibility(videoId);
1086
+ visibility[videoId] = check;
1087
+ if (check.visible === false) {
1088
+ throw new Error(check.reason ?? `Video ${videoId} is not visible`);
1089
+ }
1090
+ }
1091
+ }
1092
+ const idList = parsed.ids.map((id) => `'${id}'`).join(", ");
1093
+ const assetRows = await withResilience(
1094
+ () => customer.query(
1095
+ `SELECT asset.resource_name, asset.youtube_video_asset.youtube_video_id
1096
+ FROM asset
1097
+ WHERE asset.youtube_video_asset.youtube_video_id IN (${idList})`
1098
+ ),
1099
+ "updateVideoAdVideos.findAssets"
1100
+ );
1101
+ const assetByVideoId = /* @__PURE__ */ new Map();
1102
+ for (const row of assetRows) {
1103
+ const vid = row?.asset?.youtube_video_asset?.youtube_video_id;
1104
+ if (vid && !assetByVideoId.has(vid)) assetByVideoId.set(vid, row.asset.resource_name);
1105
+ }
1106
+ const toCreate = parsed.ids.filter((id) => !assetByVideoId.has(id));
1107
+ const createdAssets = [];
1108
+ if (toCreate.length > 0) {
1109
+ const createResp = await withResilience(
1110
+ () => customer.assets.create(
1111
+ toCreate.map((videoId) => {
1112
+ const title = visibility[videoId]?.title ?? `YouTube video ${videoId}`;
1113
+ return {
1114
+ name: title,
1115
+ type: enums.AssetType.YOUTUBE_VIDEO,
1116
+ youtube_video_asset: {
1117
+ youtube_video_id: videoId,
1118
+ youtube_video_title: title
1119
+ }
1120
+ };
1121
+ })
1122
+ ),
1123
+ "updateVideoAdVideos.createAssets"
1124
+ );
1125
+ const createResults = createResp.results || [];
1126
+ createResults.forEach((r, i) => {
1127
+ assetByVideoId.set(toCreate[i], r.resource_name);
1128
+ createdAssets.push(r.resource_name);
1129
+ });
1130
+ }
1131
+ const requestedRNs = parsed.ids.map((id) => assetByVideoId.get(id)).filter(Boolean);
1132
+ const plan = planVideoUpdate(currentVideos, requestedRNs, mode);
1133
+ if (plan.videos.length === 0) {
1134
+ throw new Error("Refusing to leave the ad with zero videos (mode=replace with empty target).");
1135
+ }
1136
+ if (plan.unchanged) {
1137
+ return {
1138
+ success: true,
1139
+ unchanged: true,
1140
+ ad_id: adId,
1141
+ ad_resource_name: `customers/${cleanId}/ads/${adId}`,
1142
+ mode,
1143
+ videos_before: currentVideos.length,
1144
+ videos_after: plan.videos.length,
1145
+ added_assets: [],
1146
+ skipped_existing: plan.skipped_existing,
1147
+ created_assets: createdAssets,
1148
+ video_titles: Object.fromEntries(
1149
+ Object.entries(visibility).map(([id, v]) => [id, v.title ?? null])
1150
+ ),
1151
+ message: "All requested videos are already on the ad \u2014 no mutate sent."
1152
+ };
1153
+ }
1154
+ const resource = buildVideoAdUpdateResource(cleanId, adId, plan.videos);
1155
+ await withResilience(
1156
+ () => customer.mutateResources([
1157
+ {
1158
+ entity: "ad",
1159
+ operation: "update",
1160
+ resource
1161
+ }
1162
+ ]),
1163
+ "updateVideoAdVideos"
1164
+ );
1165
+ await this.autoLabelCreated(customerId, [adGroupAdRN], "ad");
1166
+ return {
1167
+ success: true,
1168
+ unchanged: false,
1169
+ ad_id: adId,
1170
+ ad_resource_name: `customers/${cleanId}/ads/${adId}`,
1171
+ mode,
1172
+ videos_before: currentVideos.length,
1173
+ videos_after: plan.videos.length,
1174
+ added_assets: plan.added,
1175
+ skipped_existing: plan.skipped_existing,
1176
+ created_assets: createdAssets,
1177
+ video_titles: Object.fromEntries(
1178
+ Object.entries(visibility).map(([id, v]) => [id, v.title ?? null])
1179
+ )
1180
+ };
1181
+ }
1030
1182
  // Create keywords (paused by default, auto-labeled for discoverability)
1031
1183
  async createKeywords(customerId, keywords) {
1032
1184
  const customer = this.getCustomer(customerId);
@@ -4101,6 +4253,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4101
4253
  try {
4102
4254
  const result = await getAdsManager().createDemandGenMultiAssetAd(customerId, {
4103
4255
  ad_group_id: args?.ad_group_id,
4256
+ name: args?.name,
4104
4257
  final_urls: args?.final_urls,
4105
4258
  business_name: args?.business_name,
4106
4259
  call_to_action: args?.call_to_action,
@@ -4160,6 +4313,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4160
4313
  };
4161
4314
  }
4162
4315
  }
4316
+ case "google_ads_update_video_ad_videos": {
4317
+ const customerId = args?.customer_id || "";
4318
+ try {
4319
+ const result = await getAdsManager().updateVideoAdVideos(customerId, {
4320
+ ad_id: args?.ad_id,
4321
+ videos: args?.videos,
4322
+ mode: args?.mode,
4323
+ skip_visibility_check: args?.skip_visibility_check
4324
+ });
4325
+ return {
4326
+ content: [{
4327
+ type: "text",
4328
+ text: JSON.stringify({
4329
+ ...result,
4330
+ message: result.message ?? `Video ad updated in place: ${result.videos_before} \u2192 ${result.videos_after} videos (${result.added_assets.length} added).`
4331
+ }, null, 2)
4332
+ }]
4333
+ };
4334
+ } catch (e) {
4335
+ return {
4336
+ content: [{
4337
+ type: "text",
4338
+ text: JSON.stringify({ success: false, error: e.message }, null, 2)
4339
+ }]
4340
+ };
4341
+ }
4342
+ }
4163
4343
  case "google_ads_create_page_feed": {
4164
4344
  const customerId = args?.customer_id || "";
4165
4345
  const campaignId = args?.campaign_id;
package/dist/tools.js CHANGED
@@ -1014,6 +1014,10 @@ const tools = [
1014
1014
  properties: {
1015
1015
  customer_id: { type: "string" },
1016
1016
  ad_group_id: { type: "string" },
1017
+ name: {
1018
+ type: "string",
1019
+ description: "Optional internal ad name (not user-facing) shown in the UI's Ad column \u2014 e.g. 'Gartner CIO AI - NAM Gov'. Set it to avoid the generic auto-name ('Ad 1'). NOTE: immutable after creation on DG multi-asset ads, so set it here."
1020
+ },
1017
1021
  final_urls: { type: "array", items: { type: "string" } },
1018
1022
  business_name: { type: "string" },
1019
1023
  call_to_action: {
@@ -1085,7 +1089,7 @@ const tools = [
1085
1089
  },
1086
1090
  {
1087
1091
  name: "google_ads_update_demand_gen_multi_asset_ad",
1088
- description: "Update headlines, long_headlines, or descriptions on an existing Demand Gen Multi-Asset ad (PAUSED or ENABLED). Validates character/count limits before the API call. Provide the ad's resource name (customers/XXX/adGroupAds/YYY~ZZZ) or numeric ad ID. Omit fields you don't want to change. Auto-labels with Claude-MM-DD-YY. Use this to fix copy, test variants, or adjust messaging without recreating ads.",
1092
+ description: "Attempt to update copy on a Demand Gen ad. WARNING: on Demand Gen MULTI-ASSET ads the entire creative \u2014 headlines, descriptions, AND images \u2014 is IMMUTABLE after creation; the Google Ads API rejects any such update with IMMUTABLE_FIELD (verified live 2026-07-12). To change ANY creative on a multi-asset ad you must RECREATE it (create a new ad + pause the old one), not update it. This tool is retained for DG ad formats that may permit copy edits; against a multi-asset ad it will error. Provide the ad's resource name (customers/XXX/adGroupAds/YYY~ZZZ) or numeric ad ID.",
1089
1093
  inputSchema: {
1090
1094
  additionalProperties: false,
1091
1095
  type: "object",
@@ -1134,6 +1138,36 @@ const tools = [
1134
1138
  ]
1135
1139
  }
1136
1140
  },
1141
+ {
1142
+ name: "google_ads_update_video_ad_videos",
1143
+ description: "Add or replace the YouTube videos on an existing video responsive ad (VIDEO campaigns, e.g. YouTube reach). The VIDEO channel is campaign-level API-locked, but AD-level video_responsive_ad.videos updates ARE allowed and edit the ad in place (same ad ID) \u2014 verified live 2026-07-18. Accepts YouTube URLs (watch/shorts/youtu.be/embed) or 11-char video IDs; finds-or-creates the YouTube video assets; preflights each new video via oEmbed (private videos are rejected by Google Ads \u2014 must be Public or Unlisted). Default mode 'append' keeps existing videos and adds new ones. Auto-labels the ad claude-MM-DD-YY.",
1144
+ inputSchema: {
1145
+ additionalProperties: false,
1146
+ type: "object",
1147
+ properties: {
1148
+ customer_id: { type: "string" },
1149
+ ad_id: {
1150
+ type: "string",
1151
+ description: "Numeric ad ID (e.g. 815638860130) or full ads resource name (customers/XXX/ads/YYY). Must be a VIDEO_RESPONSIVE_AD."
1152
+ },
1153
+ videos: {
1154
+ type: "array",
1155
+ items: { type: "string" },
1156
+ description: "YouTube video URLs or 11-char video IDs to add (or, with mode=replace, the complete target list)."
1157
+ },
1158
+ mode: {
1159
+ type: "string",
1160
+ enum: ["append", "replace"],
1161
+ description: "append (default): keep current videos, add new ones. replace: the ad ends up with exactly the given list."
1162
+ },
1163
+ skip_visibility_check: {
1164
+ type: "boolean",
1165
+ description: "Skip the oEmbed public/unlisted preflight (e.g. offline testing). Default false."
1166
+ }
1167
+ },
1168
+ required: ["customer_id", "ad_id", "videos"]
1169
+ }
1170
+ },
1137
1171
  {
1138
1172
  name: "google_ads_create_page_feed",
1139
1173
  description: "Create an AI Max / DSA page feed (AssetSet of type PAGE_FEED) and attach it to a campaign. Each URL becomes a PageFeedAsset tagged with the given label. Use this to constrain AI Max final-URL expansion to a specific list of landing pages. Returns the AssetSet resource name and the number of URLs added.",
@@ -14,6 +14,9 @@ export interface DemandGenAdInput {
14
14
  final_urls: string[];
15
15
  business_name: string;
16
16
  call_to_action: string;
17
+ /** Optional internal ad name (not user-facing). Set once at creation —
18
+ * ad.name is immutable on DG multi-asset ads after create. */
19
+ name?: string;
17
20
  marketing_image_asset_ids?: string[];
18
21
  square_marketing_image_asset_ids?: string[];
19
22
  portrait_marketing_image_asset_ids?: string[];
@@ -50,6 +53,7 @@ export interface DemandGenAdPayload {
50
53
  ad_group: string;
51
54
  status: number;
52
55
  ad: {
56
+ name?: string;
53
57
  final_urls: string[];
54
58
  demand_gen_multi_asset_ad: Record<string, any>;
55
59
  };
@@ -31,13 +31,17 @@ function buildDemandGenAdPayload(args) {
31
31
  if (input.long_headlines?.length) {
32
32
  dgAd.long_headlines = input.long_headlines.map((t) => ({ text: t }));
33
33
  }
34
+ const ad = {
35
+ final_urls: input.final_urls,
36
+ demand_gen_multi_asset_ad: dgAd
37
+ };
38
+ if (input.name) {
39
+ ad.name = input.name;
40
+ }
34
41
  return {
35
42
  ad_group: `customers/${customer_id_clean}/adGroups/${ad_group_id}`,
36
43
  status: 3,
37
- ad: {
38
- final_urls: input.final_urls,
39
- demand_gen_multi_asset_ad: dgAd
40
- }
44
+ ad
41
45
  };
42
46
  }
43
47
  function validateDemandGenAd(ad) {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Video responsive ad video management — pure helpers.
3
+ *
4
+ * Platform reality (verified live 2026-07-18): the Google Ads API rejects
5
+ * every campaign-level mutate on the VIDEO channel (MUTATE_NOT_ALLOWED),
6
+ * but AD-level updates of `video_responsive_ad.videos` succeed and edit the
7
+ * ad in place (same ad ID). Source videos must be Public or Unlisted on
8
+ * YouTube — private videos are rejected / stop serving, and their oEmbed
9
+ * endpoint returns 401/403, which is the cheap preflight fingerprint.
10
+ */
11
+ /**
12
+ * Extract an 11-char YouTube video ID from a bare ID or any common URL form
13
+ * (youtu.be/, watch?v=, /shorts/, /embed/). Returns null if unparseable.
14
+ */
15
+ export declare function parseYoutubeVideoId(input: string): string | null;
16
+ /**
17
+ * Parse + validate a list of user-supplied video URLs/IDs.
18
+ * Errors on unparseable entries, duplicates (after normalization), and
19
+ * empty input. `ids` preserves input order.
20
+ */
21
+ export declare function validateYoutubeVideoInputs(inputs: string[]): {
22
+ valid: boolean;
23
+ ids: string[];
24
+ errors: string[];
25
+ };
26
+ /**
27
+ * Compute the target video-asset list for the update.
28
+ * append: current list first (order preserved), then genuinely new assets.
29
+ * replace: exactly the requested list (deduped, order preserved).
30
+ */
31
+ export declare function planVideoUpdate(currentAssetRNs: string[], requestedAssetRNs: string[], mode: "append" | "replace"): {
32
+ videos: string[];
33
+ added: string[];
34
+ skipped_existing: string[];
35
+ unchanged: boolean;
36
+ };
37
+ /**
38
+ * Build the mutate resource for the ad-level update. NOTE: the target is the
39
+ * `ads` resource (customers/X/ads/Y), NOT `adGroupAds` — this is what makes
40
+ * the mutate legal on the VIDEO channel.
41
+ */
42
+ export declare function buildVideoAdUpdateResource(customerId: string, adId: string, assetResourceNames: string[]): {
43
+ resource_name: string;
44
+ video_responsive_ad: {
45
+ videos: Array<{
46
+ asset: string;
47
+ }>;
48
+ };
49
+ };
50
+ export interface VisibilityResult {
51
+ visible: boolean | null;
52
+ title?: string;
53
+ reason?: string;
54
+ }
55
+ /**
56
+ * Best-effort preflight: YouTube's oEmbed endpoint returns 200 (+ title) for
57
+ * Public/Unlisted videos and 401/403 for private or unavailable ones. On
58
+ * network failure the check is indeterminate — never blocks the mutate.
59
+ */
60
+ export declare function checkYoutubeVisibility(videoId: string, fetchFn?: typeof fetch): Promise<VisibilityResult>;
@@ -0,0 +1,100 @@
1
+ const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
2
+ function parseYoutubeVideoId(input) {
3
+ const trimmed = (input ?? "").trim();
4
+ if (!trimmed) return null;
5
+ if (VIDEO_ID_RE.test(trimmed)) return trimmed;
6
+ let url;
7
+ try {
8
+ url = new URL(trimmed);
9
+ } catch {
10
+ return null;
11
+ }
12
+ const host = url.hostname.replace(/^www\./, "");
13
+ let candidate = null;
14
+ if (host === "youtu.be") {
15
+ candidate = url.pathname.split("/").filter(Boolean)[0] ?? null;
16
+ } else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
17
+ const segments = url.pathname.split("/").filter(Boolean);
18
+ if (segments[0] === "watch") {
19
+ candidate = url.searchParams.get("v");
20
+ } else if (segments[0] === "shorts" || segments[0] === "embed" || segments[0] === "live") {
21
+ candidate = segments[1] ?? null;
22
+ }
23
+ }
24
+ return candidate && VIDEO_ID_RE.test(candidate) ? candidate : null;
25
+ }
26
+ function validateYoutubeVideoInputs(inputs) {
27
+ const errors = [];
28
+ const ids = [];
29
+ if (!inputs || inputs.length === 0) {
30
+ return { valid: false, ids, errors: ["No videos provided"] };
31
+ }
32
+ const seen = /* @__PURE__ */ new Set();
33
+ for (const input of inputs) {
34
+ const id = parseYoutubeVideoId(input);
35
+ if (!id) {
36
+ errors.push(`Not a YouTube video URL or 11-char video ID: "${input}"`);
37
+ continue;
38
+ }
39
+ if (seen.has(id)) {
40
+ errors.push(`Duplicate video: "${input}" resolves to ${id}, already in the list`);
41
+ continue;
42
+ }
43
+ seen.add(id);
44
+ ids.push(id);
45
+ }
46
+ return { valid: errors.length === 0, ids, errors };
47
+ }
48
+ function planVideoUpdate(currentAssetRNs, requestedAssetRNs, mode) {
49
+ const current = [...currentAssetRNs];
50
+ const requested = [...new Set(requestedAssetRNs)];
51
+ const currentSet = new Set(current);
52
+ let videos;
53
+ if (mode === "replace") {
54
+ videos = requested;
55
+ } else {
56
+ videos = [...current, ...requested.filter((rn) => !currentSet.has(rn))];
57
+ }
58
+ const added = videos.filter((rn) => !currentSet.has(rn));
59
+ const skipped_existing = mode === "append" ? requested.filter((rn) => currentSet.has(rn)) : [];
60
+ const unchanged = videos.length === current.length && videos.every((rn, i) => rn === current[i]);
61
+ return { videos, added, skipped_existing, unchanged };
62
+ }
63
+ function buildVideoAdUpdateResource(customerId, adId, assetResourceNames) {
64
+ const cleanId = customerId.replace(/-/g, "");
65
+ return {
66
+ resource_name: `customers/${cleanId}/ads/${adId}`,
67
+ video_responsive_ad: {
68
+ videos: assetResourceNames.map((asset) => ({ asset }))
69
+ }
70
+ };
71
+ }
72
+ async function checkYoutubeVisibility(videoId, fetchFn = fetch) {
73
+ try {
74
+ const resp = await fetchFn(
75
+ `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
76
+ );
77
+ if (resp.ok) {
78
+ let title;
79
+ try {
80
+ title = (await resp.json())?.title;
81
+ } catch {
82
+ }
83
+ return { visible: true, title };
84
+ }
85
+ return {
86
+ visible: false,
87
+ reason: `YouTube oEmbed returned ${resp.status} \u2014 video ${videoId} looks private or unavailable. Ads require Public or Unlisted videos.`
88
+ };
89
+ } catch (e) {
90
+ return { visible: null, reason: `visibility check failed: ${e?.message ?? e}` };
91
+ }
92
+ }
93
+ export {
94
+ buildVideoAdUpdateResource,
95
+ checkYoutubeVisibility,
96
+ parseYoutubeVideoId,
97
+ planVideoUpdate,
98
+ validateYoutubeVideoInputs
99
+ };
100
+ //# sourceMappingURL=videoAdVideos.js.map
package/dist/writeGate.js CHANGED
@@ -7,6 +7,7 @@ const WRITE_TOOLS = /* @__PURE__ */ new Set([
7
7
  "google_ads_create_shared_set",
8
8
  "google_ads_create_demand_gen_multi_asset_ad",
9
9
  "google_ads_update_demand_gen_multi_asset_ad",
10
+ "google_ads_update_video_ad_videos",
10
11
  "google_ads_create_image_asset",
11
12
  "google_ads_create_lead_form_asset",
12
13
  "google_ads_enable_items",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-google-ads",
3
3
  "mcpName": "io.github.mharnett/google-ads",
4
- "version": "1.7.0",
4
+ "version": "1.8.0",
5
5
  "description": "MCP server for Google Ads API with MCC support, 44 tools for campaign management, reporting, and optimization. Read-only by default -- mutating tools require GOOGLE_ADS_MCP_WRITE=true. All creates/updates land PAUSED.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {