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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "3bef9bb",
3
- "builtAt": "2026-09-03T17:18:06.866Z",
2
+ "sha": "8219937",
3
+ "builtAt": "2026-09-09T04:33:00.640Z",
4
4
  "embeddedSecrets": true
5
5
  }
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<import("google-ads-node/build/protos/protos.js").google.ads.googleads.v24.services.IGoogleAdsRow[]>;
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
- return client;
166
+ candidates.push(client);
166
167
  }
167
168
  }
168
- return null;
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
- return safeResponse(result, "executeGaql");
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 client = getClientFromWorkingDir(getConfig(), cwd);
3301
- if (!client) {
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
@@ -1 +1,2 @@
1
1
  export declare function buildAdGroupAdResourceName(cleanCustomerId: string, adGroupId: string | number, adId: string | number): string;
2
+ export declare function buildAdResourceName(cleanCustomerId: string, adId: string | number): string;
@@ -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.10.0",
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.1.2",
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",