mcp-google-ads 1.10.0 → 1.11.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/backfillDefaults.d.ts +19 -0
- package/dist/backfillDefaults.js +57 -0
- package/dist/build-info.json +2 -2
- package/dist/index.d.ts +101 -1
- package/dist/index.js +248 -7
- package/dist/resourceNames.d.ts +1 -0
- package/dist/resourceNames.js +5 -1
- package/dist/tools.js +69 -0
- package/dist/writeGate.js +3 -1
- package/package.json +4 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Extract the selected field paths from a GAQL query's SELECT clause. */
|
|
2
|
+
export declare function extractSelectedFields(query: string): string[];
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a dotted GAQL field path (e.g. "ad_group.optimized_targeting_enabled")
|
|
5
|
+
* to its primitive type tag (e.g. "BOOL"). Returns undefined if the path can't
|
|
6
|
+
* be resolved, or if it resolves to a nested message/enum object rather than a
|
|
7
|
+
* scalar leaf -- callers only act on an exact string type match.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveFieldType(fieldPath: string): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Google Ads' REST API omits fields at their proto3 default value (e.g.
|
|
12
|
+
* `false`) entirely from the JSON body -- our tool then serializes the row
|
|
13
|
+
* with no key at all, indistinguishable from "not returned"/error. This
|
|
14
|
+
* backfills an explicit `false` for any selected BOOL leaf field missing from
|
|
15
|
+
* a row whose parent object IS present, so callers can trust that an absent
|
|
16
|
+
* boolean key never means "unknown" -- only a truly absent parent resource
|
|
17
|
+
* (e.g. a metrics-only row) is left untouched, never fabricated.
|
|
18
|
+
*/
|
|
19
|
+
export declare function backfillOmittedBooleans<T = any>(query: string, rows: T[]): T[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parse as circJsonParse } from "circ-json";
|
|
2
|
+
import fieldsMod from "google-ads-api/build/src/protos/autogen/fields.js";
|
|
3
|
+
let cachedFieldDataTypes = null;
|
|
4
|
+
function getFieldDataTypes() {
|
|
5
|
+
if (!cachedFieldDataTypes) {
|
|
6
|
+
cachedFieldDataTypes = circJsonParse(fieldsMod.fieldDataTypes);
|
|
7
|
+
}
|
|
8
|
+
return cachedFieldDataTypes;
|
|
9
|
+
}
|
|
10
|
+
function snakeToPascal(snake) {
|
|
11
|
+
return snake.split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
12
|
+
}
|
|
13
|
+
function extractSelectedFields(query) {
|
|
14
|
+
const match = query.match(/SELECT\s+(.*?)\s+FROM\s/is);
|
|
15
|
+
if (!match) return [];
|
|
16
|
+
return match[1].split(",").map((f) => f.trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
function resolveFieldType(fieldPath) {
|
|
19
|
+
const segments = fieldPath.split(".");
|
|
20
|
+
if (segments.length < 2) return void 0;
|
|
21
|
+
const types = getFieldDataTypes();
|
|
22
|
+
let current = types[snakeToPascal(segments[0])];
|
|
23
|
+
for (let i = 1; i < segments.length && current !== void 0; i++) {
|
|
24
|
+
if (typeof current !== "object") return void 0;
|
|
25
|
+
current = current[segments[i]];
|
|
26
|
+
}
|
|
27
|
+
return typeof current === "string" ? current : void 0;
|
|
28
|
+
}
|
|
29
|
+
function backfillOmittedBooleans(query, rows) {
|
|
30
|
+
const boolPaths = extractSelectedFields(query).filter((f) => resolveFieldType(f) === "BOOL");
|
|
31
|
+
if (boolPaths.length === 0) return rows;
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
for (const path of boolPaths) {
|
|
34
|
+
const segments = path.split(".");
|
|
35
|
+
const leaf = segments[segments.length - 1];
|
|
36
|
+
let parent = row;
|
|
37
|
+
let reachable = true;
|
|
38
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
39
|
+
if (parent == null || typeof parent !== "object") {
|
|
40
|
+
reachable = false;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
parent = parent[segments[i]];
|
|
44
|
+
}
|
|
45
|
+
if (reachable && parent != null && typeof parent === "object" && !(leaf in parent)) {
|
|
46
|
+
parent[leaf] = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
backfillOmittedBooleans,
|
|
54
|
+
extractSelectedFields,
|
|
55
|
+
resolveFieldType
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=backfillDefaults.js.map
|
package/dist/build-info.json
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,23 @@ interface Config {
|
|
|
24
24
|
require_approval_for_enable: boolean;
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
+
interface ClientResolution {
|
|
28
|
+
/** "one": exactly one client matched — use `client`.
|
|
29
|
+
* "none": no client matched — `client` is null, `candidates` empty.
|
|
30
|
+
* "several": more than one client matched — `client` is null, inspect `candidates`. */
|
|
31
|
+
match: "one" | "none" | "several";
|
|
32
|
+
client: ClientConfig | null;
|
|
33
|
+
candidates: ClientConfig[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve every client whose `folder` prefixes `cwd`, or whose config key
|
|
37
|
+
* appears as a substring of `cwd` (fallback for bare per-account folders
|
|
38
|
+
* like `clients/imvu` that only a per-key match, not the shared folder
|
|
39
|
+
* value, can disambiguate). Both paths feed the same de-duplicated
|
|
40
|
+
* candidate set so a caller can never be handed a single confident answer
|
|
41
|
+
* when more than one client actually matches.
|
|
42
|
+
*/
|
|
43
|
+
export declare function getClientFromWorkingDir(config: Config, cwd: string): ClientResolution;
|
|
27
44
|
export declare class GoogleAdsManager {
|
|
28
45
|
private api;
|
|
29
46
|
private config;
|
|
@@ -285,6 +302,10 @@ export declare class GoogleAdsManager {
|
|
|
285
302
|
target_cpa_dollars: number | undefined;
|
|
286
303
|
target_roas: number | undefined;
|
|
287
304
|
}>;
|
|
305
|
+
updateCampaignSelectiveOptimization(customerId: string, campaignId: string, conversionActionIds: string[]): Promise<{
|
|
306
|
+
campaign_id: string;
|
|
307
|
+
conversion_action_ids: string[];
|
|
308
|
+
}>;
|
|
288
309
|
updateCampaignAdRotation(customerId: string, campaignId: string, mode: string): Promise<{
|
|
289
310
|
campaign_id: string;
|
|
290
311
|
campaign_name: string;
|
|
@@ -541,6 +562,85 @@ export declare class GoogleAdsManager {
|
|
|
541
562
|
preview?: undefined;
|
|
542
563
|
note?: undefined;
|
|
543
564
|
}>;
|
|
565
|
+
/**
|
|
566
|
+
* Update RSA headline/description text IN PLACE via AdService
|
|
567
|
+
* (customer.ads.update), same ad ID, no clone/pause. This is deliberately
|
|
568
|
+
* NOT the clone-and-swap pattern updateAdFinalUrls uses -- that pattern
|
|
569
|
+
* exists because RSA final_urls really does appear to reject in-place
|
|
570
|
+
* updates; headlines/descriptions do not have that problem (Google's own
|
|
571
|
+
* update_responsive_search_ad.py sample updates them the same way this
|
|
572
|
+
* method does). See the class-level history note above updateAdFinalUrls
|
|
573
|
+
* for how that distinction was confirmed. Payload shape:
|
|
574
|
+
* `customer.ads.update([{resource_name: "customers/X/ads/Y", responsive_search_ad: {...}}])`
|
|
575
|
+
* -- responsive_search_ad is a TOP-LEVEL field on the Ad resource, not
|
|
576
|
+
* nested under an "ad" sub-field (that nesting is the AdGroupAd shape,
|
|
577
|
+
* a different resource).
|
|
578
|
+
*
|
|
579
|
+
* IMPORTANT: an Ad resource can be linked into more than one ad_group_ad
|
|
580
|
+
* (confirmed live: some Neon CRM ads are shared between a base campaign
|
|
581
|
+
* and an experiment-page campaign). AdService.update mutates the shared
|
|
582
|
+
* Ad resource, so ONE call here changes every ad_group_ad that links it.
|
|
583
|
+
* Both dry-run and confirm paths surface every affected link so a caller
|
|
584
|
+
* scoped to reviewing just one of them isn't surprised.
|
|
585
|
+
*/
|
|
586
|
+
updateResponsiveSearchAdText(customerId: string, adId: string, updates: {
|
|
587
|
+
headlines?: Array<string | {
|
|
588
|
+
text: string;
|
|
589
|
+
pinned_position?: number;
|
|
590
|
+
}>;
|
|
591
|
+
descriptions?: Array<string | {
|
|
592
|
+
text: string;
|
|
593
|
+
pinned_position?: number;
|
|
594
|
+
}>;
|
|
595
|
+
}, confirm?: boolean): Promise<{
|
|
596
|
+
customer_id: string;
|
|
597
|
+
ad_id: string;
|
|
598
|
+
dry_run: boolean;
|
|
599
|
+
mechanism: string;
|
|
600
|
+
affected_ad_group_ad_links: {
|
|
601
|
+
resource_name: any;
|
|
602
|
+
ad_group_id: string;
|
|
603
|
+
ad_group_name: any;
|
|
604
|
+
campaign: any;
|
|
605
|
+
status: any;
|
|
606
|
+
}[];
|
|
607
|
+
shared_across_multiple_ad_groups: boolean;
|
|
608
|
+
headline_changes: {
|
|
609
|
+
index: number;
|
|
610
|
+
from: string;
|
|
611
|
+
to: string;
|
|
612
|
+
}[];
|
|
613
|
+
description_changes: {
|
|
614
|
+
index: number;
|
|
615
|
+
from: string;
|
|
616
|
+
to: string;
|
|
617
|
+
}[];
|
|
618
|
+
note: string;
|
|
619
|
+
} | {
|
|
620
|
+
customer_id: string;
|
|
621
|
+
ad_id: string;
|
|
622
|
+
dry_run: boolean;
|
|
623
|
+
mechanism: string;
|
|
624
|
+
affected_ad_group_ad_links: {
|
|
625
|
+
resource_name: any;
|
|
626
|
+
ad_group_id: string;
|
|
627
|
+
ad_group_name: any;
|
|
628
|
+
campaign: any;
|
|
629
|
+
status: any;
|
|
630
|
+
}[];
|
|
631
|
+
shared_across_multiple_ad_groups: boolean;
|
|
632
|
+
headline_changes: {
|
|
633
|
+
index: number;
|
|
634
|
+
from: string;
|
|
635
|
+
to: string;
|
|
636
|
+
}[];
|
|
637
|
+
description_changes: {
|
|
638
|
+
index: number;
|
|
639
|
+
from: string;
|
|
640
|
+
to: string;
|
|
641
|
+
}[];
|
|
642
|
+
note?: undefined;
|
|
643
|
+
}>;
|
|
544
644
|
getKeywordPerformance(customerId: string, options: {
|
|
545
645
|
startDate: string;
|
|
546
646
|
endDate: string;
|
|
@@ -617,7 +717,7 @@ export declare class GoogleAdsManager {
|
|
|
617
717
|
startDate: string;
|
|
618
718
|
endDate: string;
|
|
619
719
|
}): Promise<import("google-ads-node/build/protos/protos.js").google.ads.googleads.v24.services.IGoogleAdsRow[]>;
|
|
620
|
-
executeGaql(customerId: string, query: string): Promise<
|
|
720
|
+
executeGaql(customerId: string, query: string): Promise<any[]>;
|
|
621
721
|
keywordVolume(customerId: string, keywords: string[], geoTargetConstants?: string[], language?: string): Promise<any>;
|
|
622
722
|
}
|
|
623
723
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -85,7 +85,7 @@ import {
|
|
|
85
85
|
isDemandGenAdGroup
|
|
86
86
|
} from "./validateDemandGenAd.js";
|
|
87
87
|
import { GoogleAdsApi, enums } from "google-ads-api";
|
|
88
|
-
import { buildAdGroupAdResourceName } from "./resourceNames.js";
|
|
88
|
+
import { buildAdGroupAdResourceName, buildAdResourceName } from "./resourceNames.js";
|
|
89
89
|
import { readFileSync, existsSync, realpathSync } from "fs";
|
|
90
90
|
import v8 from "v8";
|
|
91
91
|
const __cliPkg = JSON.parse(readFileSync(join(__moduleDir, "..", "package.json"), "utf-8"));
|
|
@@ -160,12 +160,19 @@ function loadConfig() {
|
|
|
160
160
|
};
|
|
161
161
|
}
|
|
162
162
|
function getClientFromWorkingDir(config, cwd) {
|
|
163
|
+
const candidates = [];
|
|
163
164
|
for (const [key, client] of Object.entries(config.clients)) {
|
|
164
165
|
if (cwd.startsWith(client.folder) || cwd.includes(key)) {
|
|
165
|
-
|
|
166
|
+
candidates.push(client);
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
|
-
|
|
169
|
+
if (candidates.length === 0) {
|
|
170
|
+
return { match: "none", client: null, candidates: [] };
|
|
171
|
+
}
|
|
172
|
+
if (candidates.length === 1) {
|
|
173
|
+
return { match: "one", client: candidates[0], candidates };
|
|
174
|
+
}
|
|
175
|
+
return { match: "several", client: null, candidates };
|
|
169
176
|
}
|
|
170
177
|
function sanitizeNumericId(id) {
|
|
171
178
|
return id.replace(/[^0-9]/g, "");
|
|
@@ -180,6 +187,7 @@ import {
|
|
|
180
187
|
classifyError
|
|
181
188
|
} from "./errors.js";
|
|
182
189
|
import { withResilience, safeResponse, logger } from "./resilience.js";
|
|
190
|
+
import { backfillOmittedBooleans } from "./backfillDefaults.js";
|
|
183
191
|
import {
|
|
184
192
|
resolveCredentials,
|
|
185
193
|
readStoredCredentials,
|
|
@@ -1898,6 +1906,29 @@ class GoogleAdsManager {
|
|
|
1898
1906
|
target_roas: updates.target_roas
|
|
1899
1907
|
};
|
|
1900
1908
|
}
|
|
1909
|
+
// Set a campaign's Selective Optimization conversion actions. Full-array
|
|
1910
|
+
// replace, not additive — the conversion_action_ids list passed in entirely
|
|
1911
|
+
// replaces campaign.selective_optimization.conversion_actions.
|
|
1912
|
+
async updateCampaignSelectiveOptimization(customerId, campaignId, conversionActionIds) {
|
|
1913
|
+
const customer = this.getCustomer(customerId);
|
|
1914
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
1915
|
+
const campaignUpdate = {
|
|
1916
|
+
resource_name: `customers/${cleanId}/campaigns/${campaignId}`,
|
|
1917
|
+
selective_optimization: {
|
|
1918
|
+
conversion_actions: conversionActionIds.map(
|
|
1919
|
+
(id) => `customers/${cleanId}/conversionActions/${id}`
|
|
1920
|
+
)
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
1923
|
+
await withResilience(
|
|
1924
|
+
() => customer.campaigns.update([campaignUpdate]),
|
|
1925
|
+
"updateCampaignSelectiveOptimization.update"
|
|
1926
|
+
);
|
|
1927
|
+
return {
|
|
1928
|
+
campaign_id: campaignId,
|
|
1929
|
+
conversion_action_ids: conversionActionIds
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1901
1932
|
// Update campaign ad rotation (ad_serving_optimization_status). ROTATE
|
|
1902
1933
|
// self-reverts to OPTIMIZE after ~90 days (Google-managed); ROTATE_INDEFINITELY
|
|
1903
1934
|
// does not. Caller must pass an explicit mode — no default, so ROTATE_INDEFINITELY
|
|
@@ -2872,6 +2903,160 @@ class GoogleAdsManager {
|
|
|
2872
2903
|
swaps
|
|
2873
2904
|
};
|
|
2874
2905
|
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Update RSA headline/description text IN PLACE via AdService
|
|
2908
|
+
* (customer.ads.update), same ad ID, no clone/pause. This is deliberately
|
|
2909
|
+
* NOT the clone-and-swap pattern updateAdFinalUrls uses -- that pattern
|
|
2910
|
+
* exists because RSA final_urls really does appear to reject in-place
|
|
2911
|
+
* updates; headlines/descriptions do not have that problem (Google's own
|
|
2912
|
+
* update_responsive_search_ad.py sample updates them the same way this
|
|
2913
|
+
* method does). See the class-level history note above updateAdFinalUrls
|
|
2914
|
+
* for how that distinction was confirmed. Payload shape:
|
|
2915
|
+
* `customer.ads.update([{resource_name: "customers/X/ads/Y", responsive_search_ad: {...}}])`
|
|
2916
|
+
* -- responsive_search_ad is a TOP-LEVEL field on the Ad resource, not
|
|
2917
|
+
* nested under an "ad" sub-field (that nesting is the AdGroupAd shape,
|
|
2918
|
+
* a different resource).
|
|
2919
|
+
*
|
|
2920
|
+
* IMPORTANT: an Ad resource can be linked into more than one ad_group_ad
|
|
2921
|
+
* (confirmed live: some Neon CRM ads are shared between a base campaign
|
|
2922
|
+
* and an experiment-page campaign). AdService.update mutates the shared
|
|
2923
|
+
* Ad resource, so ONE call here changes every ad_group_ad that links it.
|
|
2924
|
+
* Both dry-run and confirm paths surface every affected link so a caller
|
|
2925
|
+
* scoped to reviewing just one of them isn't surprised.
|
|
2926
|
+
*/
|
|
2927
|
+
async updateResponsiveSearchAdText(customerId, adId, updates, confirm = false) {
|
|
2928
|
+
const customer = this.getCustomer(customerId);
|
|
2929
|
+
const cleanId = customerId.replace(/-/g, "");
|
|
2930
|
+
const cleanAdId = sanitizeNumericId(adId);
|
|
2931
|
+
if (!updates.headlines && !updates.descriptions) {
|
|
2932
|
+
throw new Error("Must provide headlines and/or descriptions to update.");
|
|
2933
|
+
}
|
|
2934
|
+
const rows = await withResilience(
|
|
2935
|
+
() => customer.query(`
|
|
2936
|
+
SELECT
|
|
2937
|
+
ad_group_ad.resource_name,
|
|
2938
|
+
ad_group_ad.ad.id,
|
|
2939
|
+
ad_group_ad.ad.type,
|
|
2940
|
+
ad_group_ad.ad.responsive_search_ad.headlines,
|
|
2941
|
+
ad_group_ad.ad.responsive_search_ad.descriptions,
|
|
2942
|
+
ad_group_ad.ad.responsive_search_ad.path1,
|
|
2943
|
+
ad_group_ad.ad.responsive_search_ad.path2,
|
|
2944
|
+
ad_group_ad.ad.final_urls,
|
|
2945
|
+
ad_group_ad.status,
|
|
2946
|
+
ad_group.id,
|
|
2947
|
+
ad_group.name,
|
|
2948
|
+
campaign.id,
|
|
2949
|
+
campaign.name
|
|
2950
|
+
FROM ad_group_ad
|
|
2951
|
+
WHERE ad_group_ad.ad.id = ${cleanAdId}
|
|
2952
|
+
AND ad_group_ad.status != 'REMOVED'
|
|
2953
|
+
`),
|
|
2954
|
+
"updateResponsiveSearchAdText.query"
|
|
2955
|
+
);
|
|
2956
|
+
if (!rows || rows.length === 0) {
|
|
2957
|
+
throw new Error(`Ad ${adId} not found (or removed).`);
|
|
2958
|
+
}
|
|
2959
|
+
const first = rows[0];
|
|
2960
|
+
const adType = first.ad_group_ad?.ad?.type;
|
|
2961
|
+
if (adType !== 15 && adType !== "RESPONSIVE_SEARCH_AD") {
|
|
2962
|
+
throw new Error(
|
|
2963
|
+
`Ad ${adId} is type ${adType}, not RESPONSIVE_SEARCH_AD. This tool only edits RSA text.`
|
|
2964
|
+
);
|
|
2965
|
+
}
|
|
2966
|
+
const rsa = first.ad_group_ad?.ad?.responsive_search_ad ?? {};
|
|
2967
|
+
const HEADLINE_PIN_REV = { 2: 1, 3: 2, 4: 3 };
|
|
2968
|
+
const DESCRIPTION_PIN_REV = { 5: 1, 6: 2 };
|
|
2969
|
+
const HEADLINE_PIN_MAP = { 1: 2, 2: 3, 3: 4 };
|
|
2970
|
+
const DESCRIPTION_PIN_MAP = { 1: 5, 2: 6 };
|
|
2971
|
+
const currentHeadlines = (rsa.headlines ?? []).map(
|
|
2972
|
+
(h) => ({
|
|
2973
|
+
text: h.text,
|
|
2974
|
+
...h.pinned_field && HEADLINE_PIN_REV[h.pinned_field] ? { pinned_position: HEADLINE_PIN_REV[h.pinned_field] } : {}
|
|
2975
|
+
})
|
|
2976
|
+
);
|
|
2977
|
+
const currentDescriptions = (rsa.descriptions ?? []).map(
|
|
2978
|
+
(d) => ({
|
|
2979
|
+
text: d.text,
|
|
2980
|
+
...d.pinned_field && DESCRIPTION_PIN_REV[d.pinned_field] ? { pinned_position: DESCRIPTION_PIN_REV[d.pinned_field] } : {}
|
|
2981
|
+
})
|
|
2982
|
+
);
|
|
2983
|
+
const newHeadlines = updates.headlines?.map((h) => typeof h === "string" ? { text: h } : h);
|
|
2984
|
+
const newDescriptions = updates.descriptions?.map((d) => typeof d === "string" ? { text: d } : d);
|
|
2985
|
+
const validation = validateRsa({
|
|
2986
|
+
headlines: (newHeadlines ?? currentHeadlines).map((h) => h.text),
|
|
2987
|
+
descriptions: (newDescriptions ?? currentDescriptions).map((d) => d.text),
|
|
2988
|
+
final_urls: first.ad_group_ad?.ad?.final_urls ?? [],
|
|
2989
|
+
path1: rsa.path1 ?? "",
|
|
2990
|
+
path2: rsa.path2 ?? "",
|
|
2991
|
+
labels: ["__auto_claude_label__"],
|
|
2992
|
+
requirePathSegments: false
|
|
2993
|
+
});
|
|
2994
|
+
if (!validation.valid) {
|
|
2995
|
+
throw new Error("RSA validation failed:\n" + validation.errors.join("\n"));
|
|
2996
|
+
}
|
|
2997
|
+
const diff = (before, after) => {
|
|
2998
|
+
if (!after) return [];
|
|
2999
|
+
const changes = [];
|
|
3000
|
+
after.forEach((a, i) => {
|
|
3001
|
+
const b = before[i]?.text;
|
|
3002
|
+
if (b !== a.text) changes.push({ index: i, from: b ?? "(none)", to: a.text });
|
|
3003
|
+
});
|
|
3004
|
+
return changes;
|
|
3005
|
+
};
|
|
3006
|
+
const affectedLinks = rows.map((r) => ({
|
|
3007
|
+
resource_name: r.ad_group_ad?.resource_name,
|
|
3008
|
+
ad_group_id: String(r.ad_group?.id ?? ""),
|
|
3009
|
+
ad_group_name: r.ad_group?.name ?? "",
|
|
3010
|
+
campaign: r.campaign?.name ?? "",
|
|
3011
|
+
status: r.ad_group_ad?.status
|
|
3012
|
+
}));
|
|
3013
|
+
const headlineChanges = diff(currentHeadlines, newHeadlines);
|
|
3014
|
+
const descriptionChanges = diff(currentDescriptions, newDescriptions);
|
|
3015
|
+
if (!confirm) {
|
|
3016
|
+
return {
|
|
3017
|
+
customer_id: cleanId,
|
|
3018
|
+
ad_id: cleanAdId,
|
|
3019
|
+
dry_run: true,
|
|
3020
|
+
mechanism: "in-place update via AdService (customer.ads.update)",
|
|
3021
|
+
affected_ad_group_ad_links: affectedLinks,
|
|
3022
|
+
shared_across_multiple_ad_groups: affectedLinks.length > 1,
|
|
3023
|
+
headline_changes: headlineChanges,
|
|
3024
|
+
description_changes: descriptionChanges,
|
|
3025
|
+
note: "Set confirm=true to apply. Edits the shared Ad resource directly -- if shared_across_multiple_ad_groups is true, EVERY link above gets this change in one mutation, not just the one you queried for."
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
const adUpdate = { resource_name: buildAdResourceName(cleanId, cleanAdId) };
|
|
3029
|
+
if (newHeadlines) {
|
|
3030
|
+
adUpdate.responsive_search_ad = {
|
|
3031
|
+
...adUpdate.responsive_search_ad ?? {},
|
|
3032
|
+
headlines: newHeadlines.map((h) => ({
|
|
3033
|
+
text: h.text,
|
|
3034
|
+
...h.pinned_position && HEADLINE_PIN_MAP[h.pinned_position] ? { pinned_field: HEADLINE_PIN_MAP[h.pinned_position] } : {}
|
|
3035
|
+
}))
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
if (newDescriptions) {
|
|
3039
|
+
adUpdate.responsive_search_ad = {
|
|
3040
|
+
...adUpdate.responsive_search_ad ?? {},
|
|
3041
|
+
descriptions: newDescriptions.map((d) => ({
|
|
3042
|
+
text: d.text,
|
|
3043
|
+
...d.pinned_position && DESCRIPTION_PIN_MAP[d.pinned_position] ? { pinned_field: DESCRIPTION_PIN_MAP[d.pinned_position] } : {}
|
|
3044
|
+
}))
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
await withResilience(() => customer.ads.update([adUpdate]), "updateResponsiveSearchAdText.mutate");
|
|
3048
|
+
await this.autoLabelCreated(customerId, affectedLinks.map((l) => l.resource_name).filter(Boolean), "ad");
|
|
3049
|
+
return {
|
|
3050
|
+
customer_id: cleanId,
|
|
3051
|
+
ad_id: cleanAdId,
|
|
3052
|
+
dry_run: false,
|
|
3053
|
+
mechanism: "in-place update via AdService (customer.ads.update)",
|
|
3054
|
+
affected_ad_group_ad_links: affectedLinks,
|
|
3055
|
+
shared_across_multiple_ad_groups: affectedLinks.length > 1,
|
|
3056
|
+
headline_changes: headlineChanges,
|
|
3057
|
+
description_changes: descriptionChanges
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
2875
3060
|
// ============================================
|
|
2876
3061
|
// REPORTING METHODS
|
|
2877
3062
|
// ============================================
|
|
@@ -3236,7 +3421,8 @@ class GoogleAdsManager {
|
|
|
3236
3421
|
async executeGaql(customerId, query) {
|
|
3237
3422
|
const customer = this.getCustomer(customerId);
|
|
3238
3423
|
const result = await withResilience(() => customer.query(query), "executeGaql");
|
|
3239
|
-
|
|
3424
|
+
const backfilled = backfillOmittedBooleans(query, result);
|
|
3425
|
+
return safeResponse(backfilled, "executeGaql");
|
|
3240
3426
|
}
|
|
3241
3427
|
async keywordVolume(customerId, keywords, geoTargetConstants = ["geoTargetConstants/2840"], language = "languageConstants/1000") {
|
|
3242
3428
|
const customer = this.getCustomer(customerId);
|
|
@@ -3297,8 +3483,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3297
3483
|
switch (name) {
|
|
3298
3484
|
case "google_ads_get_client_context": {
|
|
3299
3485
|
const cwd = args?.working_directory;
|
|
3300
|
-
const
|
|
3301
|
-
if (
|
|
3486
|
+
const resolution = getClientFromWorkingDir(getConfig(), cwd);
|
|
3487
|
+
if (resolution.match === "none") {
|
|
3302
3488
|
return {
|
|
3303
3489
|
content: [{
|
|
3304
3490
|
type: "text",
|
|
@@ -3314,6 +3500,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3314
3500
|
}]
|
|
3315
3501
|
};
|
|
3316
3502
|
}
|
|
3503
|
+
if (resolution.match === "several") {
|
|
3504
|
+
return {
|
|
3505
|
+
content: [{
|
|
3506
|
+
type: "text",
|
|
3507
|
+
text: JSON.stringify({
|
|
3508
|
+
ambiguous: true,
|
|
3509
|
+
error: "Multiple clients match this working directory \u2014 specify which account explicitly",
|
|
3510
|
+
working_directory: cwd,
|
|
3511
|
+
candidates: resolution.candidates.map((c) => ({
|
|
3512
|
+
client_name: c.name,
|
|
3513
|
+
customer_id: c.customer_id,
|
|
3514
|
+
folder: c.folder,
|
|
3515
|
+
mcc_id: c.mcc_customer_id || getConfig().google_ads.mcc_customer_id
|
|
3516
|
+
}))
|
|
3517
|
+
}, null, 2)
|
|
3518
|
+
}]
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
const client = resolution.client;
|
|
3317
3522
|
return {
|
|
3318
3523
|
content: [{
|
|
3319
3524
|
type: "text",
|
|
@@ -4234,6 +4439,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4234
4439
|
}]
|
|
4235
4440
|
};
|
|
4236
4441
|
}
|
|
4442
|
+
case "google_ads_update_campaign_selective_optimization": {
|
|
4443
|
+
const customerId = args?.customer_id || "";
|
|
4444
|
+
const campaignId = args?.campaign_id;
|
|
4445
|
+
const conversionActionIds = args?.conversion_action_ids || [];
|
|
4446
|
+
const result = await getAdsManager().updateCampaignSelectiveOptimization(
|
|
4447
|
+
customerId,
|
|
4448
|
+
campaignId,
|
|
4449
|
+
conversionActionIds
|
|
4450
|
+
);
|
|
4451
|
+
return {
|
|
4452
|
+
content: [{
|
|
4453
|
+
type: "text",
|
|
4454
|
+
text: JSON.stringify({ success: true, ...result }, null, 2)
|
|
4455
|
+
}]
|
|
4456
|
+
};
|
|
4457
|
+
}
|
|
4237
4458
|
case "google_ads_detach_portfolio_bid_strategy": {
|
|
4238
4459
|
const customerId = args?.customer_id || "";
|
|
4239
4460
|
const campaignId = args?.campaign_id;
|
|
@@ -4914,6 +5135,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4914
5135
|
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
4915
5136
|
}
|
|
4916
5137
|
}
|
|
5138
|
+
case "google_ads_update_responsive_search_ad_text": {
|
|
5139
|
+
const customerId = args?.customer_id || "";
|
|
5140
|
+
const confirm = args?.confirm === true;
|
|
5141
|
+
if (confirm) assertWriteAllowed(name);
|
|
5142
|
+
try {
|
|
5143
|
+
const result = await getAdsManager().updateResponsiveSearchAdText(
|
|
5144
|
+
customerId,
|
|
5145
|
+
args?.ad_id,
|
|
5146
|
+
{
|
|
5147
|
+
headlines: args?.headlines,
|
|
5148
|
+
descriptions: args?.descriptions
|
|
5149
|
+
},
|
|
5150
|
+
confirm
|
|
5151
|
+
);
|
|
5152
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }, null, 2) }] };
|
|
5153
|
+
} catch (e) {
|
|
5154
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: false, error: e.message }, null, 2) }] };
|
|
5155
|
+
}
|
|
5156
|
+
}
|
|
4917
5157
|
default:
|
|
4918
5158
|
throw new Error(`Unknown tool: ${name}`);
|
|
4919
5159
|
}
|
|
@@ -4998,6 +5238,7 @@ if (isMainModule()) {
|
|
|
4998
5238
|
main().catch((err) => logger.error({ error: err.message, stack: err.stack }, "Fatal startup error"));
|
|
4999
5239
|
}
|
|
5000
5240
|
export {
|
|
5001
|
-
GoogleAdsManager
|
|
5241
|
+
GoogleAdsManager,
|
|
5242
|
+
getClientFromWorkingDir
|
|
5002
5243
|
};
|
|
5003
5244
|
//# sourceMappingURL=index.js.map
|
package/dist/resourceNames.d.ts
CHANGED
package/dist/resourceNames.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
function buildAdGroupAdResourceName(cleanCustomerId, adGroupId, adId) {
|
|
2
2
|
return `customers/${cleanCustomerId}/adGroupAds/${adGroupId}~${adId}`;
|
|
3
3
|
}
|
|
4
|
+
function buildAdResourceName(cleanCustomerId, adId) {
|
|
5
|
+
return `customers/${cleanCustomerId}/ads/${adId}`;
|
|
6
|
+
}
|
|
4
7
|
export {
|
|
5
|
-
buildAdGroupAdResourceName
|
|
8
|
+
buildAdGroupAdResourceName,
|
|
9
|
+
buildAdResourceName
|
|
6
10
|
};
|
|
7
11
|
//# sourceMappingURL=resourceNames.js.map
|
package/dist/tools.js
CHANGED
|
@@ -785,6 +785,24 @@ const tools = [
|
|
|
785
785
|
required: ["campaign_id"]
|
|
786
786
|
}
|
|
787
787
|
},
|
|
788
|
+
{
|
|
789
|
+
name: "google_ads_update_campaign_selective_optimization",
|
|
790
|
+
description: "Set a campaign's Selective Optimization conversion actions \u2014 the subset of conversion actions a Maximize Conversions / Maximize Conversion Value campaign optimizes bidding toward. FULL-ARRAY REPLACE, not additive: the conversion_action_ids list you pass entirely replaces whatever conversion actions are currently set, it does not merge with them. Pass every conversion action ID you want active, not just the ones to add.",
|
|
791
|
+
inputSchema: {
|
|
792
|
+
additionalProperties: false,
|
|
793
|
+
type: "object",
|
|
794
|
+
properties: {
|
|
795
|
+
customer_id: { type: "string" },
|
|
796
|
+
campaign_id: { type: "string", description: "The numeric string campaign ID to update" },
|
|
797
|
+
conversion_action_ids: {
|
|
798
|
+
type: "array",
|
|
799
|
+
items: { type: "string", description: "Numeric string conversion action ID" },
|
|
800
|
+
description: "Full replacement list of conversion action IDs for selective optimization. This REPLACES the existing set entirely \u2014 it is not additive."
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
required: ["campaign_id", "conversion_action_ids"]
|
|
804
|
+
}
|
|
805
|
+
},
|
|
788
806
|
{
|
|
789
807
|
name: "google_ads_detach_portfolio_bid_strategy",
|
|
790
808
|
description: "Detach a campaign from its portfolio (cross-campaign) bid strategy, dropping it to campaign-level Maximize Conversions with no target. This is the ONLY sanctioned way to break a portfolio bid-strategy attachment \u2014 google_ads_update_campaign_bidding and google_ads_update_campaign_budget (create_new_budget) both refuse to do this silently and point here instead. IRREVERSIBLE: re-attaching or re-applying tCPA/tROAS targets afterward is a separate manual step. Requires confirm: true.",
|
|
@@ -1490,6 +1508,57 @@ const tools = [
|
|
|
1490
1508
|
required: ["ad_group_id", "ad_ids", "new_final_url"]
|
|
1491
1509
|
}
|
|
1492
1510
|
},
|
|
1511
|
+
{
|
|
1512
|
+
name: "google_ads_update_responsive_search_ad_text",
|
|
1513
|
+
description: "Update an RSA's headline/description text IN PLACE via AdService \u2014 same ad ID, no new ad created, no pause/clone. Use this instead of google_ads_create_responsive_search_ad + google_ads_pause_items for a text-only edit. WARNING: an Ad resource can be linked into more than one ad group (e.g. a base campaign and an experiment-page campaign sharing unedited ads); this tool edits the shared Ad resource, so one call changes EVERY linked ad_group_ad \u2014 check `affected_ad_group_ad_links` / `shared_across_multiple_ad_groups` in the dry-run before confirming. Pass only headlines and/or descriptions you want changed; omitted fields are left untouched. DRY-RUN BY DEFAULT: omit `confirm` or pass `confirm: false` to preview old\u2192new text and the affected links.",
|
|
1514
|
+
inputSchema: {
|
|
1515
|
+
additionalProperties: false,
|
|
1516
|
+
type: "object",
|
|
1517
|
+
properties: {
|
|
1518
|
+
customer_id: { type: "string" },
|
|
1519
|
+
ad_id: { type: "string", description: "Numeric ad ID of the RESPONSIVE_SEARCH_AD to edit." },
|
|
1520
|
+
headlines: {
|
|
1521
|
+
type: "array",
|
|
1522
|
+
description: "Optional: the FULL replacement headline list (3-15 items, \u226430 chars each). Omit to leave headlines untouched.",
|
|
1523
|
+
items: {
|
|
1524
|
+
oneOf: [
|
|
1525
|
+
{ type: "string" },
|
|
1526
|
+
{
|
|
1527
|
+
type: "object",
|
|
1528
|
+
properties: {
|
|
1529
|
+
text: { type: "string" },
|
|
1530
|
+
pinned_position: { type: "number", description: "Pin to position 1, 2, or 3" }
|
|
1531
|
+
},
|
|
1532
|
+
required: ["text"]
|
|
1533
|
+
}
|
|
1534
|
+
]
|
|
1535
|
+
}
|
|
1536
|
+
},
|
|
1537
|
+
descriptions: {
|
|
1538
|
+
type: "array",
|
|
1539
|
+
description: "Optional: the FULL replacement description list (2-4 items, \u226490 chars each). Omit to leave descriptions untouched.",
|
|
1540
|
+
items: {
|
|
1541
|
+
oneOf: [
|
|
1542
|
+
{ type: "string" },
|
|
1543
|
+
{
|
|
1544
|
+
type: "object",
|
|
1545
|
+
properties: {
|
|
1546
|
+
text: { type: "string" },
|
|
1547
|
+
pinned_position: { type: "number", description: "Pin to position 1 or 2" }
|
|
1548
|
+
},
|
|
1549
|
+
required: ["text"]
|
|
1550
|
+
}
|
|
1551
|
+
]
|
|
1552
|
+
}
|
|
1553
|
+
},
|
|
1554
|
+
confirm: {
|
|
1555
|
+
type: "boolean",
|
|
1556
|
+
description: "Must be true to apply. Omit or false for dry-run preview."
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
required: ["ad_id"]
|
|
1560
|
+
}
|
|
1561
|
+
},
|
|
1493
1562
|
{
|
|
1494
1563
|
name: "google_ads_rename_ad_group",
|
|
1495
1564
|
description: "Rename an ad group. DRY-RUN BY DEFAULT: omit `confirm` or pass `confirm: false` to preview the change.",
|
package/dist/writeGate.js
CHANGED
|
@@ -8,6 +8,7 @@ const WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
8
8
|
"google_ads_create_demand_gen_multi_asset_ad",
|
|
9
9
|
"google_ads_update_demand_gen_multi_asset_ad",
|
|
10
10
|
"google_ads_update_video_ad_videos",
|
|
11
|
+
"google_ads_update_responsive_search_ad_text",
|
|
11
12
|
"google_ads_create_image_asset",
|
|
12
13
|
"google_ads_create_lead_form_asset",
|
|
13
14
|
"google_ads_enable_items",
|
|
@@ -51,7 +52,8 @@ const WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
51
52
|
"google_ads_set_campaign_location_targeting",
|
|
52
53
|
"google_ads_attach_user_list_audience",
|
|
53
54
|
"google_ads_create_and_attach_audience_bundle",
|
|
54
|
-
"google_ads_update_campaign_ad_rotation"
|
|
55
|
+
"google_ads_update_campaign_ad_rotation",
|
|
56
|
+
"google_ads_update_campaign_selective_optimization"
|
|
55
57
|
]);
|
|
56
58
|
const gate = createWriteGate({
|
|
57
59
|
writeTools: WRITE_TOOLS,
|
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.11.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": {
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"test": "vitest run",
|
|
51
51
|
"test:portability": "vitest run src/portability.test.ts",
|
|
52
52
|
"prepublishOnly": "node scripts/check-embedded.mjs && npm run build && npm test",
|
|
53
|
-
"smoke": "scripts/healthcheck.sh"
|
|
53
|
+
"smoke": "scripts/healthcheck.sh",
|
|
54
|
+
"scan:secrets": "node scripts/scan-history-secrets.mjs --repo ."
|
|
54
55
|
},
|
|
55
56
|
"keywords": [
|
|
56
57
|
"mcp",
|
|
@@ -91,7 +92,7 @@
|
|
|
91
92
|
"zod": "^3.22.4"
|
|
92
93
|
},
|
|
93
94
|
"devDependencies": {
|
|
94
|
-
"@drak-marketing/mcp-test-harness": "^0.
|
|
95
|
+
"@drak-marketing/mcp-test-harness": "^0.2.3",
|
|
95
96
|
"@types/node": "^20.10.0",
|
|
96
97
|
"@types/prompts": "^2.4.9",
|
|
97
98
|
"esbuild": "^0.28.0",
|