mcp-google-ads 1.3.0 → 1.4.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/README.md CHANGED
@@ -103,6 +103,18 @@ Alternatively, set credentials via environment variables (these override `config
103
103
  | `GOOGLE_ADS_CLIENT_ID` | Yes | OAuth 2.0 client ID |
104
104
  | `GOOGLE_ADS_CLIENT_SECRET` | Yes | OAuth 2.0 client secret |
105
105
  | `GOOGLE_ADS_REFRESH_TOKEN` | Yes | OAuth 2.0 refresh token |
106
+ | `GOOGLE_ADS_MCP_WRITE` | No | Set to `true` to expose mutating tools (create/update/pause/enable/remove/apply). Default: read-only. |
107
+
108
+ ### Read-only by default
109
+
110
+ The server ships read-only. Mutating tools (anything that creates, updates,
111
+ pauses, enables, removes, links, or applies) are hidden from the tool list
112
+ until you set `GOOGLE_ADS_MCP_WRITE=true` in the MCP server environment.
113
+ If a write tool is somehow invoked without that flag, the server returns a
114
+ clear error pointing at the env var.
115
+
116
+ This is deliberate: a casual chat message like "activate the Fundraising
117
+ campaign" should not move live ad spend without an explicit opt-in.
106
118
 
107
119
  ### 4. Add to Claude Code
108
120
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "a2cf43a",
3
- "builtAt": "2026-04-17T21:13:12.209Z",
2
+ "sha": "94b326c",
3
+ "builtAt": "2026-04-18T17:26:41.136Z",
4
4
  "embeddedSecrets": true
5
5
  }
@@ -23,7 +23,7 @@ function buildCampaignCreatePayload(input) {
23
23
  campaign.network_settings = {
24
24
  target_google_search: false,
25
25
  target_search_network: false,
26
- target_content_network: false,
26
+ target_content_network: true,
27
27
  target_partner_search_network: false
28
28
  };
29
29
  }
package/dist/index.js CHANGED
@@ -11,6 +11,11 @@ import {
11
11
  ListToolsRequestSchema
12
12
  } from "@modelcontextprotocol/sdk/types.js";
13
13
  import { tools } from "./tools.js";
14
+ import {
15
+ assertWriteAllowed,
16
+ filterTools,
17
+ isWriteEnabled
18
+ } from "./writeGate.js";
14
19
  import { validateRsa } from "./validateRsa.js";
15
20
  import {
16
21
  validateRemoveInput,
@@ -36,7 +41,8 @@ import {
36
41
  } from "./imageAsset.js";
37
42
  import {
38
43
  validateDemandGenAd,
39
- buildDemandGenAdPayload
44
+ buildDemandGenAdPayload,
45
+ isDemandGenAdGroup
40
46
  } from "./validateDemandGenAd.js";
41
47
  import { GoogleAdsApi, enums } from "google-ads-api";
42
48
  import { readFileSync, existsSync } from "fs";
@@ -729,7 +735,7 @@ class GoogleAdsManager {
729
735
  const cleanId = customerId.replace(/-/g, "");
730
736
  const agRows = await withResilience(
731
737
  () => customer.query(
732
- `SELECT ad_group.id, ad_group.type FROM ad_group WHERE ad_group.id = ${sanitizeNumericId(
738
+ `SELECT ad_group.id, ad_group.type, campaign.advertising_channel_type FROM ad_group WHERE ad_group.id = ${sanitizeNumericId(
733
739
  input.ad_group_id
734
740
  )}`
735
741
  ),
@@ -738,11 +744,11 @@ class GoogleAdsManager {
738
744
  if (!agRows || agRows.length === 0) {
739
745
  throw new Error(`Ad group ${input.ad_group_id} not found`);
740
746
  }
741
- const agType = agRows[0]?.ad_group?.type;
742
- const isDgAdGroup = agType === 21 || agType === "DEMAND_GEN_MULTI_ASSET_AD_GROUP" || agType === "21";
743
- if (!isDgAdGroup) {
747
+ if (!isDemandGenAdGroup(agRows[0])) {
748
+ const agType = agRows[0]?.ad_group?.type;
749
+ const cType = agRows[0]?.campaign?.advertising_channel_type;
744
750
  throw new Error(
745
- `Ad group ${input.ad_group_id} has type '${agType}', not DEMAND_GEN_MULTI_ASSET_AD_GROUP. Use google_ads_create_ad_group with type=DEMAND_GEN_MULTI_ASSET_AD_GROUP first.`
751
+ `Ad group ${input.ad_group_id} is not a Demand Gen ad group (ad_group.type='${agType}', campaign.advertising_channel_type='${cType}'). It must belong to a DEMAND_GEN campaign.`
746
752
  );
747
753
  }
748
754
  const payload = buildDemandGenAdPayload({
@@ -1637,11 +1643,12 @@ const server = new Server(
1637
1643
  }
1638
1644
  );
1639
1645
  server.setRequestHandler(ListToolsRequestSchema, async () => {
1640
- return { tools };
1646
+ return { tools: filterTools(tools) };
1641
1647
  });
1642
1648
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1643
1649
  const { name, arguments: args } = request.params;
1644
1650
  try {
1651
+ assertWriteAllowed(name);
1645
1652
  switch (name) {
1646
1653
  case "google_ads_get_client_context": {
1647
1654
  const cwd = args?.working_directory;
@@ -2598,6 +2605,11 @@ async function main() {
2598
2605
  logger.warn({ error: err.message }, "Auth check returned non-auth error (may be OK)");
2599
2606
  }
2600
2607
  }
2608
+ const writeMode = isWriteEnabled();
2609
+ logger.info(
2610
+ { writeEnabled: writeMode, envVar: "GOOGLE_ADS_MCP_WRITE" },
2611
+ writeMode ? "Write operations ENABLED (mutating tools exposed)" : "Read-only mode (write tools hidden -- set GOOGLE_ADS_MCP_WRITE=true to enable)"
2612
+ );
2601
2613
  const transport = new StdioServerTransport();
2602
2614
  await server.connect(transport);
2603
2615
  logger.info("MCP Google Ads server running");
@@ -60,3 +60,15 @@ export declare function buildDemandGenAdPayload(args: {
60
60
  input: DemandGenAdInput;
61
61
  }): DemandGenAdPayload;
62
62
  export declare function validateDemandGenAd(ad: DemandGenAdInput): DemandGenAdValidationResult;
63
+ /**
64
+ * Decide whether a GAQL row describes a Demand Gen ad group. Accepts two
65
+ * orthogonal signals:
66
+ * 1) ad_group.type matches DEMAND_GEN_MULTI_ASSET_AD_GROUP (proto value 21
67
+ * or its string name when future libs add it).
68
+ * 2) campaign.advertising_channel_type === DEMAND_GEN (14). This is the
69
+ * authoritative check because DG campaigns can only contain DG ad groups,
70
+ * and google-ads-api v23 returns undefined for ad_group.type when the
71
+ * stored proto value isn't in its local enum map.
72
+ */
73
+ export declare function isDemandGenAdGroup(row: any): boolean;
74
+ export declare function normalizeCallToAction(input: string): string;
@@ -12,7 +12,7 @@ function buildDemandGenAdPayload(args) {
12
12
  const assetRef = (id) => ({ asset: `customers/${customer_id_clean}/assets/${id}` });
13
13
  const dgAd = {
14
14
  business_name: input.business_name,
15
- call_to_action_text: input.call_to_action,
15
+ call_to_action_text: normalizeCallToAction(input.call_to_action),
16
16
  marketing_images: input.marketing_image_asset_ids.map(assetRef),
17
17
  headlines: input.headlines.map((h) => ({ text: headlineText(h) })),
18
18
  descriptions: input.descriptions.map((t) => ({ text: t }))
@@ -83,6 +83,37 @@ function validateDemandGenAd(ad) {
83
83
  });
84
84
  return { valid: errors.length === 0, errors };
85
85
  }
86
+ const DEMAND_GEN_CHANNEL_TYPE = 14;
87
+ function isDemandGenAdGroup(row) {
88
+ if (!row) return false;
89
+ const agType = row.ad_group?.type;
90
+ const campaignChannelType = row.campaign?.advertising_channel_type;
91
+ return agType === 21 || agType === "21" || agType === "DEMAND_GEN_MULTI_ASSET_AD_GROUP" || campaignChannelType === DEMAND_GEN_CHANNEL_TYPE || campaignChannelType === "DEMAND_GEN";
92
+ }
93
+ const CTA_DISPLAY_MAP = {
94
+ LEARN_MORE: "Learn more",
95
+ GET_QUOTE: "Get quote",
96
+ APPLY_NOW: "Apply now",
97
+ SIGN_UP: "Sign up",
98
+ CONTACT_US: "Contact us",
99
+ SUBSCRIBE: "Subscribe",
100
+ DOWNLOAD: "Download",
101
+ BOOK_NOW: "Book now",
102
+ SHOP_NOW: "Shop now",
103
+ BUY_NOW: "Buy now",
104
+ DONATE_NOW: "Donate now",
105
+ ORDER_NOW: "Order now",
106
+ PLAY_NOW: "Play now",
107
+ SEE_MORE: "See more",
108
+ START_NOW: "Start now",
109
+ VISIT_SITE: "Visit site",
110
+ WATCH_NOW: "Watch now"
111
+ };
112
+ function normalizeCallToAction(input) {
113
+ if (!input) return input;
114
+ const key = input.toUpperCase();
115
+ return CTA_DISPLAY_MAP[key] ?? input;
116
+ }
86
117
  export {
87
118
  MAX_DESCRIPTIONS,
88
119
  MAX_DESCRIPTION_LEN,
@@ -91,6 +122,8 @@ export {
91
122
  MAX_LONG_HEADLINES,
92
123
  MAX_LONG_HEADLINE_LEN,
93
124
  buildDemandGenAdPayload,
125
+ isDemandGenAdGroup,
126
+ normalizeCallToAction,
94
127
  validateDemandGenAd
95
128
  };
96
129
  //# sourceMappingURL=validateDemandGenAd.js.map
@@ -0,0 +1,18 @@
1
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ /**
3
+ * Tools that mutate Google Ads state. These are hidden from the tool list
4
+ * and refused at call time unless GOOGLE_ADS_MCP_WRITE=true.
5
+ *
6
+ * Adding a new tool? Put it in this set if it creates, modifies, pauses,
7
+ * enables, removes, links, unlinks, or applies anything.
8
+ */
9
+ export declare const WRITE_TOOLS: ReadonlySet<string>;
10
+ export declare function isWriteTool(name: string): boolean;
11
+ export declare function isWriteEnabled(env?: NodeJS.ProcessEnv): boolean;
12
+ export declare function filterTools(allTools: readonly Tool[], env?: NodeJS.ProcessEnv): Tool[];
13
+ export declare const WRITE_DISABLED_MESSAGE = "Write operations are disabled. Set GOOGLE_ADS_MCP_WRITE=true in the MCP server environment to enable mutating tools (create/update/pause/enable/remove/apply).";
14
+ /**
15
+ * Assert that a tool call is allowed under the current write-mode setting.
16
+ * Throws a clear Error if the tool mutates state and writes are disabled.
17
+ */
18
+ export declare function assertWriteAllowed(toolName: string, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,54 @@
1
+ const WRITE_TOOLS = /* @__PURE__ */ new Set([
2
+ "google_ads_create_campaign",
3
+ "google_ads_create_ad_group",
4
+ "google_ads_create_responsive_search_ad",
5
+ "google_ads_create_keywords",
6
+ "google_ads_create_shared_set",
7
+ "google_ads_create_demand_gen_multi_asset_ad",
8
+ "google_ads_create_image_asset",
9
+ "google_ads_enable_items",
10
+ "google_ads_pause_items",
11
+ "google_ads_pause_keywords",
12
+ "google_ads_pause_asset_links",
13
+ "google_ads_remove_items",
14
+ "google_ads_remove_shared_negatives",
15
+ "google_ads_remove_campaign_negatives",
16
+ "google_ads_remove_adgroup_negatives",
17
+ "google_ads_add_shared_negatives",
18
+ "google_ads_add_campaign_negatives",
19
+ "google_ads_link_shared_set",
20
+ "google_ads_unlink_shared_set",
21
+ "google_ads_apply_label",
22
+ "google_ads_update_campaign_tracking",
23
+ "google_ads_update_campaign_budget",
24
+ "google_ads_update_campaign_bidding",
25
+ "google_ads_update_asset_urls"
26
+ ]);
27
+ function isWriteTool(name) {
28
+ return WRITE_TOOLS.has(name);
29
+ }
30
+ function isWriteEnabled(env = process.env) {
31
+ const v = (env.GOOGLE_ADS_MCP_WRITE || "").trim().toLowerCase();
32
+ return v === "true" || v === "1" || v === "yes";
33
+ }
34
+ function filterTools(allTools, env = process.env) {
35
+ if (isWriteEnabled(env)) return [...allTools];
36
+ return allTools.filter((t) => !WRITE_TOOLS.has(t.name));
37
+ }
38
+ const WRITE_DISABLED_MESSAGE = "Write operations are disabled. Set GOOGLE_ADS_MCP_WRITE=true in the MCP server environment to enable mutating tools (create/update/pause/enable/remove/apply).";
39
+ function assertWriteAllowed(toolName, env = process.env) {
40
+ if (!isWriteTool(toolName)) return;
41
+ if (isWriteEnabled(env)) return;
42
+ throw new Error(
43
+ `Tool "${toolName}" is a write operation. ${WRITE_DISABLED_MESSAGE}`
44
+ );
45
+ }
46
+ export {
47
+ WRITE_DISABLED_MESSAGE,
48
+ WRITE_TOOLS,
49
+ assertWriteAllowed,
50
+ filterTools,
51
+ isWriteEnabled,
52
+ isWriteTool
53
+ };
54
+ //# sourceMappingURL=writeGate.js.map
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "mcp-google-ads",
3
3
  "mcpName": "io.github.mharnett/google-ads",
4
- "version": "1.3.0",
5
- "description": "MCP server for Google Ads API with MCC support, 36 tools for campaign management, reporting, and optimization -- now includes Demand Gen campaign creation end-to-end. Safe by default -- all changes created PAUSED.",
4
+ "version": "1.4.0",
5
+ "description": "MCP server for Google Ads API with MCC support, 41 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": {
8
8
  "mcp-google-ads": "dist/index.js",