mcp-reddit-ads 1.0.14 → 1.1.2

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
@@ -47,6 +47,11 @@ Set credentials via environment variables:
47
47
  | `REDDIT_CLIENT_ID` | OAuth app client ID |
48
48
  | `REDDIT_CLIENT_SECRET` | OAuth app client secret |
49
49
  | `REDDIT_REFRESH_TOKEN` | OAuth refresh token with ads scopes |
50
+ | `REDDIT_ADS_MCP_WRITE` | Set to `true` to enable mutating tools (create/update/pause/enable). Unset = read-only (default). |
51
+
52
+ ### Read-only by default
53
+
54
+ As of v1.1.0 the MCP starts in **read-only mode**. The 10 read/report/targeting tools are always exposed, but the 8 mutating tools (create/update campaigns, ad groups, ads, and bulk pause/enable) are hidden from the tool list and refused at call time unless `REDDIT_ADS_MCP_WRITE=true` is set in the server's environment. This guards against a casual chat message accidentally mutating live ad spend. Enable writes deliberately, for the sessions where you actually intend to ship changes.
50
55
 
51
56
  ### 3. Config File
52
57
 
@@ -1 +1 @@
1
- {"sha":"1d8c954","builtAt":"2026-04-09T23:19:14.252Z"}
1
+ {"sha":"19917ed","builtAt":"2026-07-09T14:42:15.009Z"}
package/dist/errors.js CHANGED
@@ -55,7 +55,8 @@ export function classifyError(error) {
55
55
  message.includes("Unauthorized") ||
56
56
  message.includes("Forbidden") ||
57
57
  bodyError?.code === 401) {
58
- return new RedditAdsAuthError(`Reddit Ads auth failed: ${message}. Refresh token may be expired. Update REDDIT_REFRESH_TOKEN in Keychain.`, error);
58
+ return new RedditAdsAuthError(`Reddit Ads auth failed: ${message}. Refresh token may be expired. Update your REDDIT_REFRESH_TOKEN environment variable.` +
59
+ (process.platform === "darwin" ? ` On macOS: security add-generic-password -a reddit-ads-mcp -s REDDIT_REFRESH_TOKEN -w '<token>' -U` : ""), error);
59
60
  }
60
61
  if (status === 429 || message.includes("rate limit") || message.includes("Rate limit")) {
61
62
  const retryMs = error?.retryAfterMs || 60_000;
package/dist/index.js CHANGED
@@ -4,15 +4,18 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { readFileSync, existsSync } from "fs";
6
6
  import { join, dirname } from "path";
7
+ import { fileURLToPath } from "url";
7
8
  import { RedditAdsAuthError, RedditAdsRateLimitError, RedditAdsServiceError, classifyError, validateCredentials, } from "./errors.js";
8
9
  import { tools } from "./tools.js";
9
10
  import { withResilience, safeResponse, logger } from "./resilience.js";
11
+ import { filterTools, assertWriteAllowed, isWriteEnabled } from "./writeGate.js";
12
+ import { checkForUpdate } from "mcp-updatenotifier";
10
13
  import v8 from "v8";
11
14
  // CLI package info
12
- const __cliPkg = JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf-8"));
15
+ const __cliPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
13
16
  // Log build fingerprint at startup
14
17
  try {
15
- const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
18
+ const __buildInfoDir = dirname(fileURLToPath(import.meta.url));
16
19
  const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
17
20
  console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
18
21
  }
@@ -30,6 +33,8 @@ const __semverLt = (a, b) => { const pa = a.split(".").map(Number), pb = b.split
30
33
  if (__semverLt(__cliPkg.version, __minimumSafeVersion)) {
31
34
  console.error(`[WARNING] Running deprecated version ${__cliPkg.version}. Minimum safe version is ${__minimumSafeVersion}. Please upgrade.`);
32
35
  }
36
+ // Fire-and-forget npm outdated check. Non-blocking; any error is swallowed.
37
+ void checkForUpdate(__cliPkg.name, __cliPkg.version).catch(() => { });
33
38
  // CLI flags
34
39
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
35
40
  console.error(`${__cliPkg.name} v${__cliPkg.version}\n`);
@@ -60,7 +65,7 @@ if (heapLimit < 256 * 1024 * 1024) {
60
65
  const envTrimmed = (key) => (process.env[key] || "").trim().replace(/^["']|["']$/g, "");
61
66
  function loadConfig() {
62
67
  // Try config.json first
63
- const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
68
+ const configPath = join(dirname(fileURLToPath(import.meta.url)), "..", "config.json");
64
69
  let config;
65
70
  if (existsSync(configPath)) {
66
71
  config = JSON.parse(readFileSync(configPath, "utf-8"));
@@ -119,7 +124,9 @@ class RedditAdsManager {
119
124
  this.config = config;
120
125
  const creds = validateCredentials();
121
126
  if (!creds.valid) {
122
- const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. MCP will not function. Check run-mcp.sh and Keychain entries.`;
127
+ const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. ` +
128
+ `Set these environment variables before starting the server.` +
129
+ (process.platform === "darwin" ? ` On macOS, tokens can be stored in Keychain and loaded via run-mcp.sh.` : "");
123
130
  console.error(msg);
124
131
  throw new RedditAdsAuthError(msg);
125
132
  }
@@ -152,6 +159,27 @@ class RedditAdsManager {
152
159
  const data = await resp.json();
153
160
  this.accessToken = data.access_token;
154
161
  this.tokenExpiry = Date.now() + ((data.expires_in || 3600) - 60) * 1000;
162
+ // Persist rotated refresh token so restarts use the latest
163
+ if (data.refresh_token && data.refresh_token !== auth.refresh_token) {
164
+ auth.refresh_token = data.refresh_token;
165
+ if (process.platform === "darwin") {
166
+ try {
167
+ const { execFileSync } = await import("child_process");
168
+ try {
169
+ execFileSync("security", ["delete-generic-password", "-a", "reddit-ads-mcp", "-s", "REDDIT_REFRESH_TOKEN"], { stdio: "ignore" });
170
+ }
171
+ catch { /* may not exist yet */ }
172
+ execFileSync("security", ["add-generic-password", "-a", "reddit-ads-mcp", "-s", "REDDIT_REFRESH_TOKEN", "-w", data.refresh_token]);
173
+ console.error("[token] Rotated refresh token persisted to Keychain");
174
+ }
175
+ catch (err) {
176
+ console.error("[token] WARNING: Failed to persist rotated refresh token to Keychain:", err);
177
+ }
178
+ }
179
+ else {
180
+ console.error("[token] Refresh token rotated. Update your REDDIT_REFRESH_TOKEN environment variable to persist across restarts.");
181
+ }
182
+ }
155
183
  return this.accessToken;
156
184
  }
157
185
  async apiCall(method, path, options) {
@@ -326,13 +354,14 @@ class RedditAdsManager {
326
354
  const config = loadConfig();
327
355
  const adsManager = new RedditAdsManager(config);
328
356
  const server = new Server({ name: __cliPkg.name, version: __cliPkg.version }, { capabilities: { tools: {} } });
329
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
357
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: filterTools(tools) }));
330
358
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
331
359
  const { name, arguments: args } = request.params;
332
360
  const ok = (data) => ({
333
361
  content: [{ type: "text", text: JSON.stringify(safeResponse(data, name), null, 2) }],
334
362
  });
335
363
  try {
364
+ assertWriteAllowed(name);
336
365
  const accountId = () => {
337
366
  const id = args?.account_id;
338
367
  if (id)
@@ -585,7 +614,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
585
614
  server: __cliPkg.name,
586
615
  };
587
616
  if (error instanceof RedditAdsAuthError) {
588
- response.action_required = "Re-authenticate: refresh token may be expired. Run oauth_flow.py and update Keychain.";
617
+ response.action_required = "Re-authenticate: refresh token may be expired. Update your REDDIT_REFRESH_TOKEN environment variable." +
618
+ (process.platform === "darwin" ? " On macOS: security add-generic-password -a reddit-ads-mcp -s REDDIT_REFRESH_TOKEN -w '<token>' -U" : "");
589
619
  }
590
620
  else if (error instanceof RedditAdsRateLimitError) {
591
621
  response.retry_after_ms = error.retryAfterMs;
@@ -615,6 +645,10 @@ async function main() {
615
645
  console.error(`[STARTUP WARNING] Auth check FAILED: ${err.message}`);
616
646
  console.error(`[STARTUP WARNING] MCP will start but API calls may fail until auth is fixed.`);
617
647
  }
648
+ const writeMode = isWriteEnabled()
649
+ ? "WRITE ENABLED (REDDIT_ADS_MCP_WRITE=true) -- mutating tools are exposed"
650
+ : "READ-ONLY (default) -- set REDDIT_ADS_MCP_WRITE=true to enable mutating tools";
651
+ console.error(`[startup] Write mode: ${writeMode}`);
618
652
  const transport = new StdioServerTransport();
619
653
  await server.connect(transport);
620
654
  console.error("[startup] MCP Reddit Ads server running");
@@ -0,0 +1,18 @@
1
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ /**
3
+ * Tools that mutate Reddit Ads state. These are hidden from the tool list
4
+ * and refused at call time unless REDDIT_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 REDDIT_ADS_MCP_WRITE=true in the MCP server environment to enable mutating tools (create/update/pause/enable).";
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,44 @@
1
+ import { createWriteGate } from "mcp-write-gate";
2
+ /**
3
+ * Tools that mutate Reddit Ads state. These are hidden from the tool list
4
+ * and refused at call time unless REDDIT_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 const WRITE_TOOLS = new Set([
10
+ "reddit_ads_create_campaign",
11
+ "reddit_ads_update_campaign",
12
+ "reddit_ads_create_ad_group",
13
+ "reddit_ads_update_ad_group",
14
+ "reddit_ads_create_ad",
15
+ "reddit_ads_update_ad",
16
+ "reddit_ads_pause_items",
17
+ "reddit_ads_enable_items",
18
+ ]);
19
+ const gate = createWriteGate({
20
+ writeTools: WRITE_TOOLS,
21
+ envPrefix: "REDDIT_ADS",
22
+ });
23
+ export function isWriteTool(name) {
24
+ return gate.isWriteTool(name);
25
+ }
26
+ export function isWriteEnabled(env = process.env) {
27
+ return gate.isWriteEnabled(env);
28
+ }
29
+ export function filterTools(allTools, env = process.env) {
30
+ return gate.filterTools(allTools, env);
31
+ }
32
+ export const WRITE_DISABLED_MESSAGE = "Write operations are disabled. Set REDDIT_ADS_MCP_WRITE=true in the MCP server environment to enable mutating tools (create/update/pause/enable).";
33
+ /**
34
+ * Assert that a tool call is allowed under the current write-mode setting.
35
+ * Throws a clear Error if the tool mutates state and writes are disabled.
36
+ */
37
+ export function assertWriteAllowed(toolName, env = process.env) {
38
+ try {
39
+ gate.assertWriteAllowed(toolName, env);
40
+ }
41
+ catch (e) {
42
+ throw new Error(`Tool "${toolName}" is a write operation. ${WRITE_DISABLED_MESSAGE}`);
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-reddit-ads",
3
3
  "mcpName": "io.github.mharnett/reddit-ads",
4
- "version": "1.0.14",
4
+ "version": "1.1.2",
5
5
  "description": "MCP server for Reddit Ads API v3 with campaign, ad group, ad management, and performance reporting.",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -25,7 +25,8 @@
25
25
  "build": "tsc && node -e \"fs=require('fs');cp=require('child_process');sha=cp.execSync('git rev-parse --short HEAD 2>/dev/null||echo unknown').toString().trim();fs.writeFileSync('dist/build-info.json',JSON.stringify({sha,builtAt:new Date().toISOString()}))\"",
26
26
  "start": "node dist/index.js",
27
27
  "dev": "tsx src/index.ts",
28
- "test": "vitest run"
28
+ "test": "vitest run",
29
+ "smoke": "scripts/healthcheck.sh"
29
30
  },
30
31
  "author": "drak-marketing",
31
32
  "license": "MIT",
@@ -35,13 +36,20 @@
35
36
  "dependencies": {
36
37
  "@modelcontextprotocol/sdk": "^0.5.0",
37
38
  "cockatiel": "^3.2.1",
39
+ "mcp-updatenotifier": "^1.0.0",
40
+ "mcp-write-gate": "^1.0.0",
38
41
  "pino": "^8.21.0",
39
42
  "pino-pretty": "^13.1.3"
40
43
  },
41
44
  "devDependencies": {
45
+ "@drak-marketing/mcp-test-harness": "^0.1.2",
42
46
  "@types/node": "^20.10.0",
43
47
  "tsx": "^4.7.0",
44
48
  "typescript": "^5.3.0",
45
49
  "vitest": "^4.0.18"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/mharnett/mcp-reddit-ads.git"
46
54
  }
47
55
  }