mcp-reddit-ads 1.0.13 → 1.1.1
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 +5 -0
- package/dist/build-info.json +1 -1
- package/dist/errors.js +2 -1
- package/dist/index.js +43 -4
- package/dist/updateNotifier.d.ts +7 -0
- package/dist/updateNotifier.js +59 -0
- package/dist/writeGate.d.ts +18 -0
- package/dist/writeGate.js +41 -0
- package/package.json +2 -1
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
|
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sha":"
|
|
1
|
+
{"sha":"58320cf","builtAt":"2026-04-18T18:09:07.962Z"}
|
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
|
|
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
|
@@ -7,6 +7,8 @@ import { join, dirname } from "path";
|
|
|
7
7
|
import { RedditAdsAuthError, RedditAdsRateLimitError, RedditAdsServiceError, classifyError, validateCredentials, } from "./errors.js";
|
|
8
8
|
import { tools } from "./tools.js";
|
|
9
9
|
import { withResilience, safeResponse, logger } from "./resilience.js";
|
|
10
|
+
import { filterTools, assertWriteAllowed, isWriteEnabled } from "./writeGate.js";
|
|
11
|
+
import { checkForUpdate } from "./updateNotifier.js";
|
|
10
12
|
import v8 from "v8";
|
|
11
13
|
// CLI package info
|
|
12
14
|
const __cliPkg = JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf-8"));
|
|
@@ -21,9 +23,17 @@ catch {
|
|
|
21
23
|
}
|
|
22
24
|
// Version safety: warn if running a deprecated or dangerously old version
|
|
23
25
|
const __minimumSafeVersion = "1.0.5"; // minimum version with input sanitization
|
|
24
|
-
|
|
26
|
+
const __semverLt = (a, b) => { const pa = a.split(".").map(Number), pb = b.split(".").map(Number); for (let i = 0; i < 3; i++) {
|
|
27
|
+
if ((pa[i] || 0) < (pb[i] || 0))
|
|
28
|
+
return true;
|
|
29
|
+
if ((pa[i] || 0) > (pb[i] || 0))
|
|
30
|
+
return false;
|
|
31
|
+
} return false; };
|
|
32
|
+
if (__semverLt(__cliPkg.version, __minimumSafeVersion)) {
|
|
25
33
|
console.error(`[WARNING] Running deprecated version ${__cliPkg.version}. Minimum safe version is ${__minimumSafeVersion}. Please upgrade.`);
|
|
26
34
|
}
|
|
35
|
+
// Fire-and-forget npm outdated check. Non-blocking; any error is swallowed.
|
|
36
|
+
void checkForUpdate(__cliPkg.name, __cliPkg.version).catch(() => { });
|
|
27
37
|
// CLI flags
|
|
28
38
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
29
39
|
console.error(`${__cliPkg.name} v${__cliPkg.version}\n`);
|
|
@@ -113,7 +123,9 @@ class RedditAdsManager {
|
|
|
113
123
|
this.config = config;
|
|
114
124
|
const creds = validateCredentials();
|
|
115
125
|
if (!creds.valid) {
|
|
116
|
-
const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}.
|
|
126
|
+
const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. ` +
|
|
127
|
+
`Set these environment variables before starting the server.` +
|
|
128
|
+
(process.platform === "darwin" ? ` On macOS, tokens can be stored in Keychain and loaded via run-mcp.sh.` : "");
|
|
117
129
|
console.error(msg);
|
|
118
130
|
throw new RedditAdsAuthError(msg);
|
|
119
131
|
}
|
|
@@ -146,6 +158,27 @@ class RedditAdsManager {
|
|
|
146
158
|
const data = await resp.json();
|
|
147
159
|
this.accessToken = data.access_token;
|
|
148
160
|
this.tokenExpiry = Date.now() + ((data.expires_in || 3600) - 60) * 1000;
|
|
161
|
+
// Persist rotated refresh token so restarts use the latest
|
|
162
|
+
if (data.refresh_token && data.refresh_token !== auth.refresh_token) {
|
|
163
|
+
auth.refresh_token = data.refresh_token;
|
|
164
|
+
if (process.platform === "darwin") {
|
|
165
|
+
try {
|
|
166
|
+
const { execFileSync } = await import("child_process");
|
|
167
|
+
try {
|
|
168
|
+
execFileSync("security", ["delete-generic-password", "-a", "reddit-ads-mcp", "-s", "REDDIT_REFRESH_TOKEN"], { stdio: "ignore" });
|
|
169
|
+
}
|
|
170
|
+
catch { /* may not exist yet */ }
|
|
171
|
+
execFileSync("security", ["add-generic-password", "-a", "reddit-ads-mcp", "-s", "REDDIT_REFRESH_TOKEN", "-w", data.refresh_token]);
|
|
172
|
+
console.error("[token] Rotated refresh token persisted to Keychain");
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
console.error("[token] WARNING: Failed to persist rotated refresh token to Keychain:", err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.error("[token] Refresh token rotated. Update your REDDIT_REFRESH_TOKEN environment variable to persist across restarts.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
149
182
|
return this.accessToken;
|
|
150
183
|
}
|
|
151
184
|
async apiCall(method, path, options) {
|
|
@@ -320,13 +353,14 @@ class RedditAdsManager {
|
|
|
320
353
|
const config = loadConfig();
|
|
321
354
|
const adsManager = new RedditAdsManager(config);
|
|
322
355
|
const server = new Server({ name: __cliPkg.name, version: __cliPkg.version }, { capabilities: { tools: {} } });
|
|
323
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
356
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: filterTools(tools) }));
|
|
324
357
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
325
358
|
const { name, arguments: args } = request.params;
|
|
326
359
|
const ok = (data) => ({
|
|
327
360
|
content: [{ type: "text", text: JSON.stringify(safeResponse(data, name), null, 2) }],
|
|
328
361
|
});
|
|
329
362
|
try {
|
|
363
|
+
assertWriteAllowed(name);
|
|
330
364
|
const accountId = () => {
|
|
331
365
|
const id = args?.account_id;
|
|
332
366
|
if (id)
|
|
@@ -579,7 +613,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
579
613
|
server: __cliPkg.name,
|
|
580
614
|
};
|
|
581
615
|
if (error instanceof RedditAdsAuthError) {
|
|
582
|
-
response.action_required = "Re-authenticate: refresh token may be expired.
|
|
616
|
+
response.action_required = "Re-authenticate: refresh token may be expired. Update your REDDIT_REFRESH_TOKEN environment variable." +
|
|
617
|
+
(process.platform === "darwin" ? " On macOS: security add-generic-password -a reddit-ads-mcp -s REDDIT_REFRESH_TOKEN -w '<token>' -U" : "");
|
|
583
618
|
}
|
|
584
619
|
else if (error instanceof RedditAdsRateLimitError) {
|
|
585
620
|
response.retry_after_ms = error.retryAfterMs;
|
|
@@ -609,6 +644,10 @@ async function main() {
|
|
|
609
644
|
console.error(`[STARTUP WARNING] Auth check FAILED: ${err.message}`);
|
|
610
645
|
console.error(`[STARTUP WARNING] MCP will start but API calls may fail until auth is fixed.`);
|
|
611
646
|
}
|
|
647
|
+
const writeMode = isWriteEnabled()
|
|
648
|
+
? "WRITE ENABLED (REDDIT_ADS_MCP_WRITE=true) -- mutating tools are exposed"
|
|
649
|
+
: "READ-ONLY (default) -- set REDDIT_ADS_MCP_WRITE=true to enable mutating tools";
|
|
650
|
+
console.error(`[startup] Write mode: ${writeMode}`);
|
|
612
651
|
const transport = new StdioServerTransport();
|
|
613
652
|
await server.connect(transport);
|
|
614
653
|
console.error("[startup] MCP Reddit 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 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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools that mutate Reddit Ads state. These are hidden from the tool list
|
|
3
|
+
* and refused at call time unless REDDIT_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
|
+
"reddit_ads_create_campaign",
|
|
10
|
+
"reddit_ads_update_campaign",
|
|
11
|
+
"reddit_ads_create_ad_group",
|
|
12
|
+
"reddit_ads_update_ad_group",
|
|
13
|
+
"reddit_ads_create_ad",
|
|
14
|
+
"reddit_ads_update_ad",
|
|
15
|
+
"reddit_ads_pause_items",
|
|
16
|
+
"reddit_ads_enable_items",
|
|
17
|
+
]);
|
|
18
|
+
export function isWriteTool(name) {
|
|
19
|
+
return WRITE_TOOLS.has(name);
|
|
20
|
+
}
|
|
21
|
+
export function isWriteEnabled(env = process.env) {
|
|
22
|
+
const v = (env.REDDIT_ADS_MCP_WRITE || "").trim().toLowerCase();
|
|
23
|
+
return v === "true" || v === "1" || v === "yes";
|
|
24
|
+
}
|
|
25
|
+
export function filterTools(allTools, env = process.env) {
|
|
26
|
+
if (isWriteEnabled(env))
|
|
27
|
+
return [...allTools];
|
|
28
|
+
return allTools.filter((t) => !WRITE_TOOLS.has(t.name));
|
|
29
|
+
}
|
|
30
|
+
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).";
|
|
31
|
+
/**
|
|
32
|
+
* Assert that a tool call is allowed under the current write-mode setting.
|
|
33
|
+
* Throws a clear Error if the tool mutates state and writes are disabled.
|
|
34
|
+
*/
|
|
35
|
+
export function assertWriteAllowed(toolName, env = process.env) {
|
|
36
|
+
if (!isWriteTool(toolName))
|
|
37
|
+
return;
|
|
38
|
+
if (isWriteEnabled(env))
|
|
39
|
+
return;
|
|
40
|
+
throw new Error(`Tool "${toolName}" is a write operation. ${WRITE_DISABLED_MESSAGE}`);
|
|
41
|
+
}
|
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.
|
|
4
|
+
"version": "1.1.1",
|
|
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": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"pino-pretty": "^13.1.3"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
+
"@drak-marketing/mcp-test-harness": "^0.1.2",
|
|
42
43
|
"@types/node": "^20.10.0",
|
|
43
44
|
"tsx": "^4.7.0",
|
|
44
45
|
"typescript": "^5.3.0",
|