mcp-reddit-ads 1.0.4 → 1.0.6
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 +19 -19
- package/dist/build-info.json +1 -1
- package/dist/index.js +23 -11
- package/dist/resilience.js +46 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ npm install mcp-reddit-ads
|
|
|
20
20
|
Or clone the repository:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
git clone https://github.com/
|
|
23
|
+
git clone https://github.com/mharnett/mcp-reddit-ads.git
|
|
24
24
|
cd mcp-reddit-ads
|
|
25
25
|
npm install
|
|
26
26
|
npm run build
|
|
@@ -98,54 +98,54 @@ Environment variables take precedence over config file values.
|
|
|
98
98
|
|
|
99
99
|
| Tool | Description |
|
|
100
100
|
|---|---|
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
101
|
+
| `reddit_ads_get_client_context` | Get account info and verify API connectivity |
|
|
102
|
+
| `reddit_ads_get_accounts` | List all ad accounts accessible to the authenticated user |
|
|
103
103
|
|
|
104
104
|
### Read
|
|
105
105
|
|
|
106
106
|
| Tool | Description |
|
|
107
107
|
|---|---|
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
108
|
+
| `reddit_ads_get_campaigns` | List campaigns with optional status filter |
|
|
109
|
+
| `reddit_ads_get_ad_groups` | List ad groups for a campaign |
|
|
110
|
+
| `reddit_ads_get_ads` | List ads for an ad group |
|
|
111
|
+
| `reddit_ads_get_performance_report` | Aggregated performance metrics for campaigns/ad groups/ads |
|
|
112
|
+
| `reddit_ads_get_daily_performance` | Day-by-day performance breakdown |
|
|
113
113
|
|
|
114
114
|
### Write: Campaigns
|
|
115
115
|
|
|
116
116
|
| Tool | Description |
|
|
117
117
|
|---|---|
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
118
|
+
| `reddit_ads_create_campaign` | Create a new campaign (PAUSED by default) |
|
|
119
|
+
| `reddit_ads_update_campaign` | Update campaign name, budget, objective, or status |
|
|
120
120
|
|
|
121
121
|
### Write: Ad Groups
|
|
122
122
|
|
|
123
123
|
| Tool | Description |
|
|
124
124
|
|---|---|
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
125
|
+
| `reddit_ads_create_ad_group` | Create a new ad group with targeting (PAUSED by default) |
|
|
126
|
+
| `reddit_ads_update_ad_group` | Update ad group bid, targeting, or status |
|
|
127
127
|
|
|
128
128
|
### Write: Ads
|
|
129
129
|
|
|
130
130
|
| Tool | Description |
|
|
131
131
|
|---|---|
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
132
|
+
| `reddit_ads_create_ad` | Create a new ad with headline, body, URL, and media (PAUSED by default) |
|
|
133
|
+
| `reddit_ads_update_ad` | Update ad creative or status |
|
|
134
134
|
|
|
135
135
|
### Bulk Operations
|
|
136
136
|
|
|
137
137
|
| Tool | Description |
|
|
138
138
|
|---|---|
|
|
139
|
-
| `
|
|
140
|
-
| `
|
|
139
|
+
| `reddit_ads_pause_items` | Pause multiple campaigns, ad groups, or ads at once |
|
|
140
|
+
| `reddit_ads_enable_items` | Enable multiple campaigns, ad groups, or ads at once |
|
|
141
141
|
|
|
142
142
|
### Targeting
|
|
143
143
|
|
|
144
144
|
| Tool | Description |
|
|
145
145
|
|---|---|
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
146
|
+
| `reddit_ads_search_subreddits` | Search for subreddits by keyword for targeting |
|
|
147
|
+
| `reddit_ads_get_interest_categories` | List available interest categories for targeting |
|
|
148
|
+
| `reddit_ads_search_geo_targets` | Search for geographic targeting options (countries, regions, metros) |
|
|
149
149
|
|
|
150
150
|
## Key Conventions
|
|
151
151
|
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sha":"
|
|
1
|
+
{"sha":"6453862","builtAt":"2026-04-09T21:28:34.019Z"}
|
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
|
+
// CLI package info
|
|
11
|
+
const __cliPkg = JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf-8"));
|
|
10
12
|
// Log build fingerprint at startup
|
|
11
13
|
try {
|
|
12
14
|
const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
|
|
@@ -14,22 +16,21 @@ try {
|
|
|
14
16
|
console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
|
|
15
17
|
}
|
|
16
18
|
catch {
|
|
17
|
-
|
|
19
|
+
console.error(`[build] ${__cliPkg.name}@${__cliPkg.version} (dev mode)`);
|
|
18
20
|
}
|
|
19
21
|
// CLI flags
|
|
20
|
-
const __cliPkg = JSON.parse(readFileSync(join(dirname(new URL(import.meta.url).pathname), "..", "package.json"), "utf-8"));
|
|
21
22
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
22
|
-
console.
|
|
23
|
-
console.
|
|
24
|
-
console.
|
|
25
|
-
console.
|
|
26
|
-
console.
|
|
27
|
-
console.
|
|
28
|
-
console.
|
|
23
|
+
console.error(`${__cliPkg.name} v${__cliPkg.version}\n`);
|
|
24
|
+
console.error(`Usage: ${__cliPkg.name} [options]\n`);
|
|
25
|
+
console.error("MCP server communicating via stdio. Configure in your .mcp.json.\n");
|
|
26
|
+
console.error("Options:");
|
|
27
|
+
console.error(" --help, -h Show this help message");
|
|
28
|
+
console.error(" --version, -v Show version number");
|
|
29
|
+
console.error(`\nDocumentation: https://github.com/mharnett/mcp-reddit-ads`);
|
|
29
30
|
process.exit(0);
|
|
30
31
|
}
|
|
31
32
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
32
|
-
console.
|
|
33
|
+
console.error(__cliPkg.version);
|
|
33
34
|
process.exit(0);
|
|
34
35
|
}
|
|
35
36
|
function loadConfig() {
|
|
@@ -296,7 +297,7 @@ class RedditAdsManager {
|
|
|
296
297
|
// ============================================
|
|
297
298
|
const config = loadConfig();
|
|
298
299
|
const adsManager = new RedditAdsManager(config);
|
|
299
|
-
const server = new Server({ name:
|
|
300
|
+
const server = new Server({ name: __cliPkg.name, version: __cliPkg.version }, { capabilities: { tools: {} } });
|
|
300
301
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
301
302
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
302
303
|
const { name, arguments: args } = request.params;
|
|
@@ -533,4 +534,15 @@ async function main() {
|
|
|
533
534
|
await server.connect(transport);
|
|
534
535
|
console.error("[startup] MCP Reddit Ads server running");
|
|
535
536
|
}
|
|
537
|
+
process.on("SIGTERM", () => {
|
|
538
|
+
console.error("[shutdown] SIGTERM received, exiting");
|
|
539
|
+
process.exit(0);
|
|
540
|
+
});
|
|
541
|
+
process.on("SIGINT", () => {
|
|
542
|
+
console.error("[shutdown] SIGINT received, exiting");
|
|
543
|
+
process.exit(0);
|
|
544
|
+
});
|
|
545
|
+
process.on("SIGPIPE", () => {
|
|
546
|
+
// Client disconnected -- expected during shutdown
|
|
547
|
+
});
|
|
536
548
|
main().catch(console.error);
|
package/dist/resilience.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { retry, circuitBreaker, wrap,
|
|
1
|
+
import { retry, circuitBreaker, wrap, handleWhen, timeout, TimeoutStrategy, ExponentialBackoff, ConsecutiveBreaker, } from "cockatiel";
|
|
2
2
|
import pino from "pino";
|
|
3
3
|
// ============================================
|
|
4
4
|
// LOGGER
|
|
@@ -12,34 +12,50 @@ export const logger = pino({
|
|
|
12
12
|
colorize: true,
|
|
13
13
|
singleLine: true,
|
|
14
14
|
translateTime: "SYS:standard",
|
|
15
|
+
destination: 2, // stderr -- stdout is reserved for MCP JSON-RPC
|
|
15
16
|
},
|
|
16
17
|
},
|
|
17
18
|
}),
|
|
18
|
-
}
|
|
19
|
+
},
|
|
20
|
+
// When no transport (test mode), write to stderr directly
|
|
21
|
+
process.env.NODE_ENV === "test" ? pino.destination(2) : undefined);
|
|
19
22
|
// ============================================
|
|
20
23
|
// SAFE RESPONSE (Response Size Limiting)
|
|
21
24
|
// ============================================
|
|
22
25
|
const MAX_RESPONSE_SIZE = 200_000; // 200KB
|
|
23
26
|
export function safeResponse(data, context) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
let current = data;
|
|
28
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
29
|
+
const jsonStr = JSON.stringify(current);
|
|
30
|
+
const sizeBytes = Buffer.byteLength(jsonStr, "utf-8");
|
|
31
|
+
if (sizeBytes <= MAX_RESPONSE_SIZE)
|
|
32
|
+
return current;
|
|
33
|
+
logger.warn({ sizeBytes, maxSize: MAX_RESPONSE_SIZE, context, pass }, "Response exceeds size limit, truncating");
|
|
34
|
+
if (Array.isArray(current)) {
|
|
35
|
+
current = current.slice(0, Math.max(1, Math.floor(current.length * 0.5)));
|
|
36
|
+
continue;
|
|
31
37
|
}
|
|
32
|
-
if (typeof
|
|
33
|
-
const obj =
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
if (typeof current === "object" && current !== null) {
|
|
39
|
+
const obj = current;
|
|
40
|
+
let truncated = false;
|
|
41
|
+
for (const key of ["items", "results", "data", "rows", "tags", "triggers", "variables"]) {
|
|
42
|
+
if (Array.isArray(obj[key]) && obj[key].length > 1) {
|
|
36
43
|
obj[key] = obj[key].slice(0, Math.max(1, Math.floor(obj[key].length * 0.5)));
|
|
37
|
-
|
|
44
|
+
if ("count" in obj)
|
|
45
|
+
obj.count = obj[key].length;
|
|
46
|
+
if ("row_count" in obj)
|
|
47
|
+
obj.row_count = obj[key].length;
|
|
48
|
+
obj.truncated = true;
|
|
49
|
+
truncated = true;
|
|
50
|
+
break;
|
|
38
51
|
}
|
|
39
52
|
}
|
|
53
|
+
if (truncated)
|
|
54
|
+
continue;
|
|
40
55
|
}
|
|
56
|
+
break;
|
|
41
57
|
}
|
|
42
|
-
return
|
|
58
|
+
return current;
|
|
43
59
|
}
|
|
44
60
|
// ============================================
|
|
45
61
|
// RETRY + CIRCUIT BREAKER + TIMEOUT
|
|
@@ -48,11 +64,24 @@ const backoff = new ExponentialBackoff({
|
|
|
48
64
|
initialDelay: 100,
|
|
49
65
|
maxDelay: 5_000,
|
|
50
66
|
});
|
|
51
|
-
const
|
|
67
|
+
const isTransient = handleWhen((err) => {
|
|
68
|
+
const msg = (err?.message || "").toLowerCase();
|
|
69
|
+
const code = err?.code || err?.status;
|
|
70
|
+
if (code === 401 || code === 403 || code === 7 || code === 16)
|
|
71
|
+
return false;
|
|
72
|
+
if (msg.includes("unauthenticated") || msg.includes("permission_denied") || msg.includes("invalid_grant"))
|
|
73
|
+
return false;
|
|
74
|
+
if (code === 429 || msg.includes("rate"))
|
|
75
|
+
return true;
|
|
76
|
+
if (code >= 400 && code < 500)
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
const retryPolicy = retry(isTransient, {
|
|
52
81
|
maxAttempts: 3,
|
|
53
82
|
backoff,
|
|
54
83
|
});
|
|
55
|
-
const circuitBreakerPolicy = circuitBreaker(
|
|
84
|
+
const circuitBreakerPolicy = circuitBreaker(isTransient, {
|
|
56
85
|
halfOpenAfter: 60_000,
|
|
57
86
|
breaker: new ConsecutiveBreaker(5),
|
|
58
87
|
});
|
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.
|
|
4
|
+
"version": "1.0.6",
|
|
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": {
|