mcp-bing-ads 1.0.15 → 1.1.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
@@ -65,8 +65,24 @@ npm install mcp-bing-ads
65
65
  export BING_ADS_REFRESH_TOKEN="your_refresh_token"
66
66
  # Optional:
67
67
  export BING_ADS_CLIENT_SECRET="your_client_secret"
68
+ # Optional: opt into mutating tools (read-only by default)
69
+ export BING_ADS_MCP_WRITE="true"
68
70
  ```
69
71
 
72
+ ### Environment Variables
73
+
74
+ | Variable | Required | Default | Description |
75
+ | --- | --- | --- | --- |
76
+ | `BING_ADS_DEVELOPER_TOKEN` | yes | -- | Microsoft Advertising developer token |
77
+ | `BING_ADS_CLIENT_ID` | yes | -- | Azure AD app client ID |
78
+ | `BING_ADS_REFRESH_TOKEN` | yes | -- | OAuth refresh token |
79
+ | `BING_ADS_CLIENT_SECRET` | no | -- | Azure AD app client secret (if confidential client) |
80
+ | `BING_ADS_MCP_WRITE` | no | `false` | Set to `true`, `1`, or `yes` to expose mutating tools (pause/update/add). Any other value -- or unset -- keeps the server read-only. |
81
+
82
+ ### Read-only by default
83
+
84
+ Mutating tools (`bing_ads_pause_keywords`, `bing_ads_update_campaign_budget`, `bing_ads_add_shared_negatives`) are **hidden from the tool list and refused at call time** unless `BING_ADS_MCP_WRITE=true` is set in the server environment. This is a safety gate against casual write actions -- for example, pausing a keyword or editing a budget based on a throwaway chat message. To make write changes, set the env var explicitly in your `.mcp.json` or shell profile for the session that needs it, then unset it afterwards. Read tools (list/report/performance) are always available.
85
+
70
86
  ## Usage
71
87
 
72
88
  ### Start the server
@@ -1 +1 @@
1
- {"sha":"da8d128","builtAt":"2026-04-09T23:18:53.060Z"}
1
+ {"sha":"202808b","builtAt":"2026-04-18T18:10:54.717Z"}
package/dist/errors.js CHANGED
@@ -55,7 +55,8 @@ export function classifyError(error) {
55
55
  message.includes("AuthenticationTokenExpired") ||
56
56
  message.includes("InvalidCredentials") ||
57
57
  bodyError?.code === "AuthenticationTokenExpired") {
58
- return new BingAdsAuthError(`Bing Ads auth failed: ${message}. Refresh token may be expired. Re-authenticate and update Keychain.`, error);
58
+ return new BingAdsAuthError(`Bing Ads auth failed: ${message}. Refresh token may be expired. Run 'node get-refresh-token.cjs' and update your BING_ADS_REFRESH_TOKEN environment variable.` +
59
+ (process.platform === "darwin" ? ` On macOS: security add-generic-password -a bing-ads-mcp -s BING_ADS_REFRESH_TOKEN -w '<token>' -U` : ""), error);
59
60
  }
60
61
  if (status === 429 || message.includes("RateLimit") || message.includes("CallRateExceeded")) {
61
62
  const retryMs = 60_000;
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { readFileSync, existsSync } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { BingAdsAuthError, BingAdsRateLimitError, BingAdsServiceError, classifyError, validateCredentials, } from "./errors.js";
8
8
  import { tools } from "./tools.js";
9
+ import { filterTools, assertWriteAllowed, isWriteEnabled } from "./writeGate.js";
9
10
  import { withResilience, safeResponse, logger } from "./resilience.js";
10
11
  import v8 from "v8";
11
12
  // CLI package info
@@ -61,7 +62,9 @@ const envTrimmed = (key) => (process.env[key] || "").trim().replace(/^["']|["']$
61
62
  function loadConfig() {
62
63
  const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
63
64
  if (!existsSync(configPath)) {
64
- throw new Error(`Config file not found at ${configPath}. Create config.json from config.example.json with your client entries.`);
65
+ throw new Error(`Config file not found at ${configPath}. Create config.json from config.example.json with your client entries, ` +
66
+ `or set env vars BING_ADS_DEVELOPER_TOKEN, BING_ADS_CLIENT_ID, BING_ADS_CLIENT_SECRET, and BING_ADS_REFRESH_TOKEN. ` +
67
+ `Run 'node get-refresh-token.cjs' to obtain a refresh token.`);
65
68
  }
66
69
  return JSON.parse(readFileSync(configPath, "utf-8"));
67
70
  }
@@ -89,7 +92,9 @@ class BingAdsManager {
89
92
  // Validate credentials at startup — fail fast
90
93
  const creds = validateCredentials();
91
94
  if (!creds.valid) {
92
- const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. MCP will not function. Check run-mcp.sh and Keychain entries.`;
95
+ const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. ` +
96
+ `Set these environment variables before starting the server.` +
97
+ (process.platform === "darwin" ? ` On macOS, tokens can be stored in Keychain and loaded via run-mcp.sh.` : "");
93
98
  console.error(msg);
94
99
  throw new BingAdsAuthError(msg);
95
100
  }
@@ -599,12 +604,13 @@ const server = new Server({
599
604
  });
600
605
  // Handle list tools
601
606
  server.setRequestHandler(ListToolsRequestSchema, async () => {
602
- return { tools };
607
+ return { tools: filterTools(tools) };
603
608
  });
604
609
  // Handle tool calls
605
610
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
606
611
  const { name, arguments: args } = request.params;
607
612
  try {
613
+ assertWriteAllowed(name);
608
614
  // Resolve client from account_id or working directory context
609
615
  const resolveClient = (accountId) => {
610
616
  if (accountId) {
@@ -818,6 +824,8 @@ async function main() {
818
824
  console.error(`[STARTUP WARNING] Auth check FAILED: ${err.message}`);
819
825
  console.error(`[STARTUP WARNING] MCP will start but ALL API calls will fail until auth is fixed.`);
820
826
  }
827
+ const writeMode = isWriteEnabled();
828
+ console.error(`[startup] Write mode: ${writeMode ? "ENABLED (mutating tools exposed)" : "DISABLED (read-only; set BING_ADS_MCP_WRITE=true to enable writes)"}`);
821
829
  const transport = new StdioServerTransport();
822
830
  await server.connect(transport);
823
831
  console.error("[startup] MCP Bing Ads server running");
@@ -0,0 +1,7 @@
1
+ export type FetchLatestVersion = () => Promise<string>;
2
+ export interface UpdateNotifierDeps {
3
+ fetchLatestVersion?: FetchLatestVersion;
4
+ log?: (msg: string) => void;
5
+ env?: NodeJS.ProcessEnv;
6
+ }
7
+ export declare function checkForUpdate(pkgName: string, currentVersion: string, deps?: UpdateNotifierDeps): Promise<void>;
@@ -0,0 +1,59 @@
1
+ // Fire-and-forget npm registry check at startup. Logs to stderr when a
2
+ // newer version is available. stdout is reserved for MCP JSON-RPC, so the
3
+ // message never goes there. Silent on network error, timeout, or when the
4
+ // installed version is equal to or ahead of the registry latest.
5
+ //
6
+ // Opt out by setting MCP_DISABLE_UPDATE_CHECK=1 (CI, offline, air-gapped).
7
+ // Also skipped when NODE_ENV=test to keep vitest runs silent.
8
+ export async function checkForUpdate(pkgName, currentVersion, deps = {}) {
9
+ const env = deps.env ?? process.env;
10
+ if (env.MCP_DISABLE_UPDATE_CHECK === "1" || env.NODE_ENV === "test") {
11
+ return;
12
+ }
13
+ const log = deps.log ?? ((msg) => process.stderr.write(msg + "\n"));
14
+ const fetcher = deps.fetchLatestVersion ?? (() => defaultFetch(pkgName));
15
+ let latest;
16
+ try {
17
+ latest = await fetcher();
18
+ }
19
+ catch {
20
+ return;
21
+ }
22
+ if (!latest || !semverLt(currentVersion, latest)) {
23
+ return;
24
+ }
25
+ log(`[update] ${pkgName}@${latest} is available (running ${currentVersion}). ` +
26
+ `Upgrade: npx -y ${pkgName}@latest (and relaunch Claude Desktop).`);
27
+ }
28
+ async function defaultFetch(pkgName) {
29
+ const controller = new AbortController();
30
+ const timer = setTimeout(() => controller.abort(), 2_000);
31
+ try {
32
+ const res = await fetch(`https://registry.npmjs.org/${pkgName}/latest`, {
33
+ signal: controller.signal,
34
+ headers: { Accept: "application/json" },
35
+ });
36
+ if (!res.ok) {
37
+ throw new Error(`HTTP ${res.status}`);
38
+ }
39
+ const body = (await res.json());
40
+ if (typeof body.version !== "string") {
41
+ throw new Error("registry response missing version field");
42
+ }
43
+ return body.version;
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ function semverLt(a, b) {
50
+ const pa = a.split(".").map((x) => parseInt(x, 10) || 0);
51
+ const pb = b.split(".").map((x) => parseInt(x, 10) || 0);
52
+ for (let i = 0; i < 3; i++) {
53
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
54
+ return true;
55
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
56
+ return false;
57
+ }
58
+ return false;
59
+ }
@@ -0,0 +1,18 @@
1
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ /**
3
+ * Tools that mutate Bing Ads state. These are hidden from the tool list
4
+ * and refused at call time unless BING_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 BING_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,36 @@
1
+ /**
2
+ * Tools that mutate Bing Ads state. These are hidden from the tool list
3
+ * and refused at call time unless BING_ADS_MCP_WRITE=true.
4
+ *
5
+ * Adding a new tool? Put it in this set if it creates, modifies, pauses,
6
+ * enables, removes, links, unlinks, or applies anything.
7
+ */
8
+ export const WRITE_TOOLS = new Set([
9
+ "bing_ads_add_shared_negatives",
10
+ "bing_ads_pause_keywords",
11
+ "bing_ads_update_campaign_budget",
12
+ ]);
13
+ export function isWriteTool(name) {
14
+ return WRITE_TOOLS.has(name);
15
+ }
16
+ export function isWriteEnabled(env = process.env) {
17
+ const v = (env.BING_ADS_MCP_WRITE || "").trim().toLowerCase();
18
+ return v === "true" || v === "1" || v === "yes";
19
+ }
20
+ export function filterTools(allTools, env = process.env) {
21
+ if (isWriteEnabled(env))
22
+ return [...allTools];
23
+ return allTools.filter((t) => !WRITE_TOOLS.has(t.name));
24
+ }
25
+ export const WRITE_DISABLED_MESSAGE = "Write operations are disabled. Set BING_ADS_MCP_WRITE=true in the MCP server environment to enable mutating tools (create/update/pause/enable/remove/apply).";
26
+ /**
27
+ * Assert that a tool call is allowed under the current write-mode setting.
28
+ * Throws a clear Error if the tool mutates state and writes are disabled.
29
+ */
30
+ export function assertWriteAllowed(toolName, env = process.env) {
31
+ if (!isWriteTool(toolName))
32
+ return;
33
+ if (isWriteEnabled(env))
34
+ return;
35
+ throw new Error(`Tool "${toolName}" is a write operation. ${WRITE_DISABLED_MESSAGE}`);
36
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-bing-ads",
3
3
  "mcpName": "io.github.mharnett/bing-ads",
4
- "version": "1.0.15",
4
+ "version": "1.1.0",
5
5
  "description": "MCP server for Microsoft Advertising (Bing Ads) API with campaign, ad group, keyword, and performance reporting. First comprehensive open-source Bing Ads MCP.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -58,6 +58,7 @@
58
58
  "zod": "^3.22.4"
59
59
  },
60
60
  "devDependencies": {
61
+ "@drak-marketing/mcp-test-harness": "^0.1.2",
61
62
  "@types/node": "^20.10.0",
62
63
  "tsx": "^4.7.0",
63
64
  "typescript": "^5.3.0",