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.
- package/dist/build-info.json +2 -2
- package/dist/index.d.ts +42 -0
- package/dist/index.js +181 -1
- package/dist/tools.js +35 -1
- package/dist/validateDemandGenAd.d.ts +4 -0
- package/dist/validateDemandGenAd.js +8 -4
- package/dist/videoAdVideos.d.ts +60 -0
- package/dist/videoAdVideos.js +100 -0
- package/dist/writeGate.js +1 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
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
|
-
|
|
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: "
|
|
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.
|
|
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": {
|