mcp-reddit-ads 1.0.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/LICENSE +21 -0
- package/README.md +170 -0
- package/config.example.json +17 -0
- package/dist/build-info.json +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +60 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +499 -0
- package/dist/resilience.d.ts +3 -0
- package/dist/resilience.js +76 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +260 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 drak-marketing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# mcp-reddit-ads
|
|
2
|
+
|
|
3
|
+
MCP server for Reddit Ads API v3 -- campaign management, ad creation, performance reporting, and audience targeting via Claude.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **18 tools** covering full CRUD for campaigns, ad groups, and ads
|
|
8
|
+
- Performance reports with daily breakdowns
|
|
9
|
+
- Subreddit, interest, and geographic targeting
|
|
10
|
+
- Bulk pause/enable operations
|
|
11
|
+
- Safe by default: all new entities created in PAUSED status
|
|
12
|
+
- Budget inputs in dollars (auto-converts to Reddit's microcurrency format)
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install mcp-reddit-ads
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or clone the repository:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git clone https://github.com/drak-marketing/mcp-reddit-ads.git
|
|
24
|
+
cd mcp-reddit-ads
|
|
25
|
+
npm install
|
|
26
|
+
npm run build
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
### 1. Reddit OAuth App
|
|
32
|
+
|
|
33
|
+
Create a Reddit OAuth app at [reddit.com/prefs/apps](https://www.reddit.com/prefs/apps):
|
|
34
|
+
|
|
35
|
+
- Select "script" type
|
|
36
|
+
- Note the client ID and client secret
|
|
37
|
+
- Obtain a refresh token with `adsread adsedit read` scopes
|
|
38
|
+
|
|
39
|
+
### 2. Environment Variables
|
|
40
|
+
|
|
41
|
+
Set credentials via environment variables:
|
|
42
|
+
|
|
43
|
+
| Variable | Description |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `REDDIT_CLIENT_ID` | OAuth app client ID |
|
|
46
|
+
| `REDDIT_CLIENT_SECRET` | OAuth app client secret |
|
|
47
|
+
| `REDDIT_REFRESH_TOKEN` | OAuth refresh token with ads scopes |
|
|
48
|
+
|
|
49
|
+
### 3. Config File
|
|
50
|
+
|
|
51
|
+
Copy `config.example.json` to `config.json` and fill in defaults:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"reddit_api": {
|
|
56
|
+
"base_url": "https://ads-api.reddit.com/api/v3",
|
|
57
|
+
"auth": {
|
|
58
|
+
"client_id": "",
|
|
59
|
+
"client_secret": "",
|
|
60
|
+
"refresh_token": "",
|
|
61
|
+
"user_agent": "reddit-ad-mcp/1.0"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"defaults": {
|
|
65
|
+
"account_id": "",
|
|
66
|
+
"business_id": "",
|
|
67
|
+
"report_metrics": ["impressions", "clicks", "spend", "ctr", "cpc", "ecpm"],
|
|
68
|
+
"date_range_days": 7
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Environment variables take precedence over config file values.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
### Claude Code (.mcp.json)
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"reddit-ads": {
|
|
83
|
+
"command": "node",
|
|
84
|
+
"args": ["/path/to/mcp-reddit-ads/dist/index.js"],
|
|
85
|
+
"env": {
|
|
86
|
+
"REDDIT_CLIENT_ID": "$(security find-generic-password -a reddit-ads-mcp -s REDDIT_CLIENT_ID -w)",
|
|
87
|
+
"REDDIT_CLIENT_SECRET": "$(security find-generic-password -a reddit-ads-mcp -s REDDIT_CLIENT_SECRET -w)",
|
|
88
|
+
"REDDIT_REFRESH_TOKEN": "$(security find-generic-password -a reddit-ads-mcp -s REDDIT_REFRESH_TOKEN -w)"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Tools
|
|
96
|
+
|
|
97
|
+
### Context
|
|
98
|
+
|
|
99
|
+
| Tool | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `get_client_context` | Get account info and verify API connectivity |
|
|
102
|
+
| `get_accounts` | List all ad accounts accessible to the authenticated user |
|
|
103
|
+
|
|
104
|
+
### Read
|
|
105
|
+
|
|
106
|
+
| Tool | Description |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `get_campaigns` | List campaigns with optional status filter |
|
|
109
|
+
| `get_ad_groups` | List ad groups for a campaign |
|
|
110
|
+
| `get_ads` | List ads for an ad group |
|
|
111
|
+
| `get_performance_report` | Aggregated performance metrics for campaigns/ad groups/ads |
|
|
112
|
+
| `get_daily_performance` | Day-by-day performance breakdown |
|
|
113
|
+
|
|
114
|
+
### Write: Campaigns
|
|
115
|
+
|
|
116
|
+
| Tool | Description |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `create_campaign` | Create a new campaign (PAUSED by default) |
|
|
119
|
+
| `update_campaign` | Update campaign name, budget, objective, or status |
|
|
120
|
+
|
|
121
|
+
### Write: Ad Groups
|
|
122
|
+
|
|
123
|
+
| Tool | Description |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `create_ad_group` | Create a new ad group with targeting (PAUSED by default) |
|
|
126
|
+
| `update_ad_group` | Update ad group bid, targeting, or status |
|
|
127
|
+
|
|
128
|
+
### Write: Ads
|
|
129
|
+
|
|
130
|
+
| Tool | Description |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `create_ad` | Create a new ad with headline, body, URL, and media (PAUSED by default) |
|
|
133
|
+
| `update_ad` | Update ad creative or status |
|
|
134
|
+
|
|
135
|
+
### Bulk Operations
|
|
136
|
+
|
|
137
|
+
| Tool | Description |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `pause_items` | Pause multiple campaigns, ad groups, or ads at once |
|
|
140
|
+
| `enable_items` | Enable multiple campaigns, ad groups, or ads at once |
|
|
141
|
+
|
|
142
|
+
### Targeting
|
|
143
|
+
|
|
144
|
+
| Tool | Description |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `search_subreddits` | Search for subreddits by keyword for targeting |
|
|
147
|
+
| `get_interest_categories` | List available interest categories for targeting |
|
|
148
|
+
| `search_geo_targets` | Search for geographic targeting options (countries, regions, metros) |
|
|
149
|
+
|
|
150
|
+
## Key Conventions
|
|
151
|
+
|
|
152
|
+
- **Spend values** are returned from the API in microcurrency (1 dollar = 1,000,000 microcurrency units). Divide by 1,000,000 to get dollar amounts. Budget inputs accept dollars and auto-convert.
|
|
153
|
+
- **Dates and times** use ISO 8601 format (`YYYY-MM-DDTHH:MM:SSZ`).
|
|
154
|
+
- **New entities default to PAUSED** status. Explicitly set status to `ACTIVE` to go live.
|
|
155
|
+
- **Report metrics** default to the set configured in `config.json` but can be overridden per request.
|
|
156
|
+
|
|
157
|
+
## Architecture
|
|
158
|
+
|
|
159
|
+
- **Resilience**: Uses [cockatiel](https://github.com/connor4312/cockatiel) for retry policies and circuit breaking on API calls
|
|
160
|
+
- **Logging**: Structured logging via [pino](https://github.com/pinojs/pino)
|
|
161
|
+
- **Response truncation**: Large API responses are truncated at 200KB to stay within MCP message limits
|
|
162
|
+
- **Auth**: OAuth 2.0 refresh token flow with automatic access token renewal
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT -- see [LICENSE](LICENSE).
|
|
167
|
+
|
|
168
|
+
## Author
|
|
169
|
+
|
|
170
|
+
Built by Mark Harnett / [drak-marketing](https://github.com/drak-marketing).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reddit_api": {
|
|
3
|
+
"base_url": "https://ads-api.reddit.com/api/v3",
|
|
4
|
+
"auth": {
|
|
5
|
+
"client_id": "",
|
|
6
|
+
"client_secret": "",
|
|
7
|
+
"refresh_token": "",
|
|
8
|
+
"user_agent": "reddit-ad-mcp/1.0"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"defaults": {
|
|
12
|
+
"account_id": "",
|
|
13
|
+
"business_id": "",
|
|
14
|
+
"report_metrics": ["impressions", "clicks", "spend", "ctr", "cpc", "ecpm"],
|
|
15
|
+
"date_range_days": 7
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"sha":"c18ff2d","builtAt":"2026-04-03T23:26:48.972Z"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class RedditAdsAuthError extends Error {
|
|
2
|
+
readonly cause?: unknown | undefined;
|
|
3
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
4
|
+
}
|
|
5
|
+
export declare class RedditAdsRateLimitError extends Error {
|
|
6
|
+
readonly retryAfterMs: number;
|
|
7
|
+
constructor(retryAfterMs: number, cause?: unknown);
|
|
8
|
+
}
|
|
9
|
+
export declare class RedditAdsServiceError extends Error {
|
|
10
|
+
readonly cause?: unknown | undefined;
|
|
11
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
12
|
+
}
|
|
13
|
+
export declare function validateCredentials(): {
|
|
14
|
+
valid: boolean;
|
|
15
|
+
missing: string[];
|
|
16
|
+
};
|
|
17
|
+
export declare function classifyError(error: any): Error;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// TYPED ERRORS
|
|
3
|
+
// ============================================
|
|
4
|
+
export class RedditAdsAuthError extends Error {
|
|
5
|
+
cause;
|
|
6
|
+
constructor(message, cause) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.cause = cause;
|
|
9
|
+
this.name = "RedditAdsAuthError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class RedditAdsRateLimitError extends Error {
|
|
13
|
+
retryAfterMs;
|
|
14
|
+
constructor(retryAfterMs, cause) {
|
|
15
|
+
super(`Rate limited, retry after ${retryAfterMs}ms`);
|
|
16
|
+
this.retryAfterMs = retryAfterMs;
|
|
17
|
+
this.name = "RedditAdsRateLimitError";
|
|
18
|
+
this.cause = cause;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class RedditAdsServiceError extends Error {
|
|
22
|
+
cause;
|
|
23
|
+
constructor(message, cause) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.cause = cause;
|
|
26
|
+
this.name = "RedditAdsServiceError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ============================================
|
|
30
|
+
// STARTUP CREDENTIAL VALIDATION
|
|
31
|
+
// ============================================
|
|
32
|
+
export function validateCredentials() {
|
|
33
|
+
const required = [
|
|
34
|
+
"REDDIT_CLIENT_ID",
|
|
35
|
+
"REDDIT_CLIENT_SECRET",
|
|
36
|
+
"REDDIT_REFRESH_TOKEN",
|
|
37
|
+
];
|
|
38
|
+
const missing = required.filter((key) => !process.env[key] || process.env[key].trim() === "");
|
|
39
|
+
return { valid: missing.length === 0, missing };
|
|
40
|
+
}
|
|
41
|
+
export function classifyError(error) {
|
|
42
|
+
const message = error?.message || String(error);
|
|
43
|
+
const status = error?.status;
|
|
44
|
+
if (status === 401 ||
|
|
45
|
+
status === 403 ||
|
|
46
|
+
message.includes("invalid_grant") ||
|
|
47
|
+
message.includes("OAuth") ||
|
|
48
|
+
message.includes("Unauthorized") ||
|
|
49
|
+
message.includes("Forbidden")) {
|
|
50
|
+
return new RedditAdsAuthError(`Auth failed: ${message}. Refresh token may be expired. Update REDDIT_REFRESH_TOKEN in Keychain.`, error);
|
|
51
|
+
}
|
|
52
|
+
if (status === 429 || message.includes("rate limit") || message.includes("Rate limit")) {
|
|
53
|
+
const retryMs = error?.retryAfterMs || 60_000;
|
|
54
|
+
return new RedditAdsRateLimitError(retryMs, error);
|
|
55
|
+
}
|
|
56
|
+
if (status >= 500 || message.includes("Internal Server Error") || message.includes("Service Unavailable")) {
|
|
57
|
+
return new RedditAdsServiceError(`Reddit API server error: ${message}`, error);
|
|
58
|
+
}
|
|
59
|
+
return error;
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
import { RedditAdsAuthError, RedditAdsRateLimitError, RedditAdsServiceError, classifyError, validateCredentials, } from "./errors.js";
|
|
8
|
+
import { tools } from "./tools.js";
|
|
9
|
+
import { withResilience, safeResponse, logger } from "./resilience.js";
|
|
10
|
+
// Log build fingerprint at startup
|
|
11
|
+
try {
|
|
12
|
+
const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
|
|
13
|
+
const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
|
|
14
|
+
console.error(`[build] SHA: ${buildInfo.sha} (${buildInfo.builtAt})`);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// build-info.json not present (dev mode)
|
|
18
|
+
}
|
|
19
|
+
function loadConfig() {
|
|
20
|
+
const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
|
|
21
|
+
if (!existsSync(configPath)) {
|
|
22
|
+
throw new Error(`Config file not found at ${configPath}. Copy config.example.json to config.json.`);
|
|
23
|
+
}
|
|
24
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
25
|
+
// Environment overrides
|
|
26
|
+
if (process.env.REDDIT_CLIENT_ID)
|
|
27
|
+
config.reddit_api.auth.client_id = process.env.REDDIT_CLIENT_ID;
|
|
28
|
+
if (process.env.REDDIT_CLIENT_SECRET)
|
|
29
|
+
config.reddit_api.auth.client_secret = process.env.REDDIT_CLIENT_SECRET;
|
|
30
|
+
if (process.env.REDDIT_REFRESH_TOKEN)
|
|
31
|
+
config.reddit_api.auth.refresh_token = process.env.REDDIT_REFRESH_TOKEN;
|
|
32
|
+
if (process.env.REDDIT_ACCOUNT_ID)
|
|
33
|
+
config.defaults.account_id = process.env.REDDIT_ACCOUNT_ID;
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
// ============================================
|
|
37
|
+
// DATE HELPERS
|
|
38
|
+
// ============================================
|
|
39
|
+
function getDateRange(days) {
|
|
40
|
+
const end = new Date();
|
|
41
|
+
const start = new Date(end);
|
|
42
|
+
start.setDate(start.getDate() - days);
|
|
43
|
+
return {
|
|
44
|
+
startDate: start.toISOString().slice(0, 10),
|
|
45
|
+
endDate: end.toISOString().slice(0, 10),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// ============================================
|
|
49
|
+
// REDDIT ADS API CLIENT
|
|
50
|
+
// ============================================
|
|
51
|
+
class RedditAdsManager {
|
|
52
|
+
config;
|
|
53
|
+
accessToken = null;
|
|
54
|
+
tokenExpiry = 0;
|
|
55
|
+
constructor(config) {
|
|
56
|
+
this.config = config;
|
|
57
|
+
const creds = validateCredentials();
|
|
58
|
+
if (!creds.valid) {
|
|
59
|
+
const msg = `[STARTUP ERROR] Missing required credentials: ${creds.missing.join(", ")}. MCP will not function. Check run-mcp.sh and Keychain entries.`;
|
|
60
|
+
console.error(msg);
|
|
61
|
+
throw new RedditAdsAuthError(msg);
|
|
62
|
+
}
|
|
63
|
+
console.error("[startup] Credentials validated: all required env vars present");
|
|
64
|
+
}
|
|
65
|
+
async getAccessToken() {
|
|
66
|
+
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
67
|
+
return this.accessToken;
|
|
68
|
+
}
|
|
69
|
+
const auth = this.config.reddit_api.auth;
|
|
70
|
+
const credentials = Buffer.from(`${auth.client_id}:${auth.client_secret}`).toString("base64");
|
|
71
|
+
const resp = await fetch("https://www.reddit.com/api/v1/access_token", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
"Authorization": `Basic ${credentials}`,
|
|
75
|
+
"User-Agent": auth.user_agent,
|
|
76
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
77
|
+
},
|
|
78
|
+
body: new URLSearchParams({
|
|
79
|
+
grant_type: "refresh_token",
|
|
80
|
+
refresh_token: auth.refresh_token,
|
|
81
|
+
}).toString(),
|
|
82
|
+
});
|
|
83
|
+
if (!resp.ok) {
|
|
84
|
+
const text = await resp.text();
|
|
85
|
+
const error = new Error(`OAuth token refresh failed: ${resp.status} ${text}`);
|
|
86
|
+
error.status = resp.status;
|
|
87
|
+
throw classifyError(error);
|
|
88
|
+
}
|
|
89
|
+
const data = await resp.json();
|
|
90
|
+
this.accessToken = data.access_token;
|
|
91
|
+
this.tokenExpiry = Date.now() + ((data.expires_in || 3600) - 60) * 1000;
|
|
92
|
+
return this.accessToken;
|
|
93
|
+
}
|
|
94
|
+
async apiCall(method, path, options) {
|
|
95
|
+
const token = await this.getAccessToken();
|
|
96
|
+
const auth = this.config.reddit_api.auth;
|
|
97
|
+
let url = `${this.config.reddit_api.base_url}${path}`;
|
|
98
|
+
if (options?.params) {
|
|
99
|
+
const qs = new URLSearchParams(options.params).toString();
|
|
100
|
+
if (qs)
|
|
101
|
+
url += `?${qs}`;
|
|
102
|
+
}
|
|
103
|
+
const resp = await fetch(url, {
|
|
104
|
+
method,
|
|
105
|
+
headers: {
|
|
106
|
+
"Authorization": `Bearer ${token}`,
|
|
107
|
+
"User-Agent": auth.user_agent,
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
},
|
|
110
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
111
|
+
signal: AbortSignal.timeout(30_000),
|
|
112
|
+
});
|
|
113
|
+
if (!resp.ok) {
|
|
114
|
+
const text = await resp.text();
|
|
115
|
+
const error = new Error(`Reddit API error: ${resp.status} ${text}`);
|
|
116
|
+
error.status = resp.status;
|
|
117
|
+
throw classifyError(error);
|
|
118
|
+
}
|
|
119
|
+
return resp.json();
|
|
120
|
+
}
|
|
121
|
+
resolveAccountId(accountId) {
|
|
122
|
+
if (accountId)
|
|
123
|
+
return accountId;
|
|
124
|
+
if (this.config.defaults.account_id)
|
|
125
|
+
return this.config.defaults.account_id;
|
|
126
|
+
throw new Error("No account_id provided and no default configured");
|
|
127
|
+
}
|
|
128
|
+
// ── Read operations ──────────────────────────────────────────────
|
|
129
|
+
async getMe() {
|
|
130
|
+
return withResilience(() => this.apiCall("GET", "/me"), "getMe");
|
|
131
|
+
}
|
|
132
|
+
async getAccounts() {
|
|
133
|
+
return withResilience(async () => {
|
|
134
|
+
const businesses = await this.apiCall("GET", "/me/businesses");
|
|
135
|
+
if (businesses?.data?.length > 0) {
|
|
136
|
+
const bizId = businesses.data[0].id;
|
|
137
|
+
return this.apiCall("GET", `/businesses/${bizId}/ad_accounts`);
|
|
138
|
+
}
|
|
139
|
+
return { data: [], error: "No businesses found" };
|
|
140
|
+
}, "getAccounts");
|
|
141
|
+
}
|
|
142
|
+
async getCampaigns(accountId) {
|
|
143
|
+
return withResilience(() => this.apiCall("GET", `/ad_accounts/${accountId}/campaigns`), "getCampaigns");
|
|
144
|
+
}
|
|
145
|
+
async getAdGroups(accountId, campaignId) {
|
|
146
|
+
const params = {};
|
|
147
|
+
if (campaignId)
|
|
148
|
+
params.campaign_id = campaignId;
|
|
149
|
+
return withResilience(() => this.apiCall("GET", `/ad_accounts/${accountId}/ad_groups`, { params }), "getAdGroups");
|
|
150
|
+
}
|
|
151
|
+
async getAds(accountId, adGroupId) {
|
|
152
|
+
const params = {};
|
|
153
|
+
if (adGroupId)
|
|
154
|
+
params.ad_group_id = adGroupId;
|
|
155
|
+
return withResilience(() => this.apiCall("GET", `/ad_accounts/${accountId}/ads`, { params }), "getAds");
|
|
156
|
+
}
|
|
157
|
+
async getReport(accountId, options) {
|
|
158
|
+
const fields = options.fields || this.config.defaults.report_metrics;
|
|
159
|
+
let startDate = options.startDate;
|
|
160
|
+
let endDate = options.endDate;
|
|
161
|
+
// Ensure ISO 8601 format
|
|
162
|
+
if (!startDate.includes("T"))
|
|
163
|
+
startDate = `${startDate}T00:00:00Z`;
|
|
164
|
+
if (!endDate.includes("T"))
|
|
165
|
+
endDate = `${endDate}T00:00:00Z`;
|
|
166
|
+
const reportData = {
|
|
167
|
+
starts_at: startDate,
|
|
168
|
+
ends_at: endDate,
|
|
169
|
+
fields,
|
|
170
|
+
};
|
|
171
|
+
if (options.breakdowns) {
|
|
172
|
+
reportData.breakdowns = options.breakdowns;
|
|
173
|
+
}
|
|
174
|
+
return withResilience(() => this.apiCall("POST", `/ad_accounts/${accountId}/reports`, { body: { data: reportData } }), "getReport");
|
|
175
|
+
}
|
|
176
|
+
// ── Write operations ─────────────────────────────────────────────
|
|
177
|
+
async createCampaign(accountId, data) {
|
|
178
|
+
const body = {
|
|
179
|
+
name: data.name,
|
|
180
|
+
objective: data.objective,
|
|
181
|
+
goal_type: "DAILY_SPEND",
|
|
182
|
+
goal_value: data.dailyBudgetMicro,
|
|
183
|
+
start_time: data.startTime,
|
|
184
|
+
configured_status: data.configuredStatus || "PAUSED",
|
|
185
|
+
};
|
|
186
|
+
if (data.endTime)
|
|
187
|
+
body.end_time = data.endTime;
|
|
188
|
+
return withResilience(() => this.apiCall("POST", `/ad_accounts/${accountId}/campaigns`, { body: { data: body } }), "createCampaign");
|
|
189
|
+
}
|
|
190
|
+
async updateCampaign(campaignId, updates) {
|
|
191
|
+
return withResilience(() => this.apiCall("PUT", `/campaigns/${campaignId}`, { body: { data: updates } }), "updateCampaign");
|
|
192
|
+
}
|
|
193
|
+
async createAdGroup(accountId, data) {
|
|
194
|
+
const body = {
|
|
195
|
+
campaign_id: data.campaignId,
|
|
196
|
+
name: data.name,
|
|
197
|
+
bid_type: data.bidStrategy || "CPM",
|
|
198
|
+
bid_value: data.bidMicro,
|
|
199
|
+
start_time: data.startTime,
|
|
200
|
+
configured_status: data.configuredStatus || "PAUSED",
|
|
201
|
+
};
|
|
202
|
+
if (data.endTime)
|
|
203
|
+
body.end_time = data.endTime;
|
|
204
|
+
if (data.target)
|
|
205
|
+
body.targeting = data.target;
|
|
206
|
+
if (data.optimizationGoal)
|
|
207
|
+
body.optimization_goal = data.optimizationGoal;
|
|
208
|
+
return withResilience(() => this.apiCall("POST", `/ad_accounts/${accountId}/ad_groups`, { body: { data: body } }), "createAdGroup");
|
|
209
|
+
}
|
|
210
|
+
async updateAdGroup(adGroupId, updates) {
|
|
211
|
+
return withResilience(() => this.apiCall("PUT", `/ad_groups/${adGroupId}`, { body: { data: updates } }), "updateAdGroup");
|
|
212
|
+
}
|
|
213
|
+
async createAd(accountId, data) {
|
|
214
|
+
const adData = {
|
|
215
|
+
ad_group_id: data.adGroupId,
|
|
216
|
+
name: data.name,
|
|
217
|
+
configured_status: data.configuredStatus || "PAUSED",
|
|
218
|
+
};
|
|
219
|
+
if (data.headline)
|
|
220
|
+
adData.headline = data.headline;
|
|
221
|
+
if (data.clickUrl)
|
|
222
|
+
adData.click_url = data.clickUrl;
|
|
223
|
+
if (data.thumbnailUrl)
|
|
224
|
+
adData.thumbnail_url = data.thumbnailUrl;
|
|
225
|
+
if (data.bodyText)
|
|
226
|
+
adData.body = data.bodyText;
|
|
227
|
+
if (data.callToAction)
|
|
228
|
+
adData.call_to_action = data.callToAction;
|
|
229
|
+
if (data.creativeType)
|
|
230
|
+
adData.creative_type = data.creativeType;
|
|
231
|
+
if (data.videoUrl)
|
|
232
|
+
adData.video_url = data.videoUrl;
|
|
233
|
+
if (data.postUrl)
|
|
234
|
+
adData.post_url = data.postUrl;
|
|
235
|
+
return withResilience(() => this.apiCall("POST", `/ad_accounts/${accountId}/ads`, { body: { data: adData } }), "createAd");
|
|
236
|
+
}
|
|
237
|
+
async updateAd(adId, updates) {
|
|
238
|
+
return withResilience(() => this.apiCall("PUT", `/ads/${adId}`, { body: { data: updates } }), "updateAd");
|
|
239
|
+
}
|
|
240
|
+
// ── Targeting ────────────────────────────────────────────────────
|
|
241
|
+
async searchSubreddits(query) {
|
|
242
|
+
return withResilience(() => this.apiCall("GET", "/targeting/subreddits", { params: { query } }), "searchSubreddits");
|
|
243
|
+
}
|
|
244
|
+
async getInterestCategories() {
|
|
245
|
+
return withResilience(() => this.apiCall("GET", "/targeting/interests"), "getInterestCategories");
|
|
246
|
+
}
|
|
247
|
+
async searchGeoTargets(query) {
|
|
248
|
+
const params = {};
|
|
249
|
+
if (query)
|
|
250
|
+
params.query = query;
|
|
251
|
+
return withResilience(() => this.apiCall("GET", "/targeting/geos", { params }), "searchGeoTargets");
|
|
252
|
+
}
|
|
253
|
+
getDefaultAccountId() {
|
|
254
|
+
return this.config.defaults.account_id || null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ============================================
|
|
258
|
+
// MCP SERVER
|
|
259
|
+
// ============================================
|
|
260
|
+
const config = loadConfig();
|
|
261
|
+
const adsManager = new RedditAdsManager(config);
|
|
262
|
+
const server = new Server({ name: "mcp-reddit-ads", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
263
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
264
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
265
|
+
const { name, arguments: args } = request.params;
|
|
266
|
+
const ok = (data) => ({
|
|
267
|
+
content: [{ type: "text", text: JSON.stringify(safeResponse(data, name), null, 2) }],
|
|
268
|
+
});
|
|
269
|
+
try {
|
|
270
|
+
const accountId = () => {
|
|
271
|
+
const id = args?.account_id;
|
|
272
|
+
if (id)
|
|
273
|
+
return id;
|
|
274
|
+
const def = adsManager.getDefaultAccountId();
|
|
275
|
+
if (def)
|
|
276
|
+
return def;
|
|
277
|
+
throw new Error("No account_id provided and no default configured");
|
|
278
|
+
};
|
|
279
|
+
switch (name) {
|
|
280
|
+
// ── Context ──
|
|
281
|
+
case "reddit_ads_get_client_context": {
|
|
282
|
+
const acctId = accountId();
|
|
283
|
+
const me = await adsManager.getMe();
|
|
284
|
+
const campaigns = await adsManager.getCampaigns(acctId);
|
|
285
|
+
const campaignData = (campaigns?.data || []);
|
|
286
|
+
const active = campaignData.filter((c) => c.configured_status === "ACTIVE");
|
|
287
|
+
return ok({
|
|
288
|
+
account_id: acctId,
|
|
289
|
+
user: me?.data || {},
|
|
290
|
+
total_campaigns: campaignData.length,
|
|
291
|
+
active_campaigns: active.length,
|
|
292
|
+
active_campaign_names: active.map((c) => c.name),
|
|
293
|
+
status: "connected",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// ── Read ──
|
|
297
|
+
case "reddit_ads_get_accounts":
|
|
298
|
+
return ok(await adsManager.getAccounts());
|
|
299
|
+
case "reddit_ads_get_campaigns":
|
|
300
|
+
return ok(await adsManager.getCampaigns(accountId()));
|
|
301
|
+
case "reddit_ads_get_ad_groups":
|
|
302
|
+
return ok(await adsManager.getAdGroups(accountId(), args?.campaign_id));
|
|
303
|
+
case "reddit_ads_get_ads":
|
|
304
|
+
return ok(await adsManager.getAds(accountId(), args?.ad_group_id));
|
|
305
|
+
case "reddit_ads_get_performance_report": {
|
|
306
|
+
const acctId = accountId();
|
|
307
|
+
const { startDate, endDate } = args?.start_date && args?.end_date
|
|
308
|
+
? { startDate: args.start_date, endDate: args.end_date }
|
|
309
|
+
: getDateRange(7);
|
|
310
|
+
return ok(await adsManager.getReport(acctId, {
|
|
311
|
+
startDate,
|
|
312
|
+
endDate,
|
|
313
|
+
fields: args?.fields,
|
|
314
|
+
breakdowns: args?.breakdowns,
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
case "reddit_ads_get_daily_performance": {
|
|
318
|
+
const acctId = accountId();
|
|
319
|
+
const days = args?.days || 7;
|
|
320
|
+
const { startDate, endDate } = getDateRange(days);
|
|
321
|
+
return ok(await adsManager.getReport(acctId, {
|
|
322
|
+
startDate,
|
|
323
|
+
endDate,
|
|
324
|
+
fields: ["impressions", "clicks", "spend", "ctr", "cpc"],
|
|
325
|
+
breakdowns: ["date"],
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
// ── Write: Campaigns ──
|
|
329
|
+
case "reddit_ads_create_campaign": {
|
|
330
|
+
const acctId = accountId();
|
|
331
|
+
const budgetMicro = Math.round(args?.daily_budget_dollars * 1_000_000);
|
|
332
|
+
return ok(await adsManager.createCampaign(acctId, {
|
|
333
|
+
name: args?.name,
|
|
334
|
+
objective: args?.objective,
|
|
335
|
+
dailyBudgetMicro: budgetMicro,
|
|
336
|
+
startTime: args?.start_time,
|
|
337
|
+
endTime: args?.end_time,
|
|
338
|
+
configuredStatus: args?.configured_status || "PAUSED",
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
case "reddit_ads_update_campaign": {
|
|
342
|
+
const updates = {};
|
|
343
|
+
if (args?.name != null)
|
|
344
|
+
updates.name = args.name;
|
|
345
|
+
if (args?.daily_budget_dollars != null)
|
|
346
|
+
updates.daily_budget_micro = Math.round(args.daily_budget_dollars * 1_000_000);
|
|
347
|
+
if (args?.configured_status != null)
|
|
348
|
+
updates.configured_status = args.configured_status;
|
|
349
|
+
if (args?.end_time != null)
|
|
350
|
+
updates.end_time = args.end_time;
|
|
351
|
+
return ok(await adsManager.updateCampaign(args?.campaign_id, updates));
|
|
352
|
+
}
|
|
353
|
+
// ── Write: Ad Groups ──
|
|
354
|
+
case "reddit_ads_create_ad_group": {
|
|
355
|
+
const acctId = accountId();
|
|
356
|
+
const bidMicro = Math.round(args?.bid_dollars * 1_000_000);
|
|
357
|
+
const target = {};
|
|
358
|
+
if (args?.subreddit_names)
|
|
359
|
+
target.communities = args.subreddit_names;
|
|
360
|
+
if (args?.interest_ids)
|
|
361
|
+
target.interests = args.interest_ids;
|
|
362
|
+
if (args?.geo_country_codes)
|
|
363
|
+
target.geos = args.geo_country_codes.map(c => ({ country: c }));
|
|
364
|
+
if (args?.device_types)
|
|
365
|
+
target.devices = args.device_types;
|
|
366
|
+
return ok(await adsManager.createAdGroup(acctId, {
|
|
367
|
+
campaignId: args?.campaign_id,
|
|
368
|
+
name: args?.name,
|
|
369
|
+
bidMicro,
|
|
370
|
+
startTime: args?.start_time,
|
|
371
|
+
endTime: args?.end_time,
|
|
372
|
+
target: Object.keys(target).length > 0 ? target : undefined,
|
|
373
|
+
bidStrategy: args?.bid_strategy || "CPM",
|
|
374
|
+
configuredStatus: args?.configured_status || "PAUSED",
|
|
375
|
+
optimizationGoal: args?.optimization_goal,
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
case "reddit_ads_update_ad_group": {
|
|
379
|
+
const updates = {};
|
|
380
|
+
if (args?.name != null)
|
|
381
|
+
updates.name = args.name;
|
|
382
|
+
if (args?.bid_dollars != null)
|
|
383
|
+
updates.bid_micro = Math.round(args.bid_dollars * 1_000_000);
|
|
384
|
+
if (args?.configured_status != null)
|
|
385
|
+
updates.configured_status = args.configured_status;
|
|
386
|
+
if (args?.end_time != null)
|
|
387
|
+
updates.end_time = args.end_time;
|
|
388
|
+
return ok(await adsManager.updateAdGroup(args?.ad_group_id, updates));
|
|
389
|
+
}
|
|
390
|
+
// ── Write: Ads ──
|
|
391
|
+
case "reddit_ads_create_ad": {
|
|
392
|
+
const acctId = accountId();
|
|
393
|
+
return ok(await adsManager.createAd(acctId, {
|
|
394
|
+
adGroupId: args?.ad_group_id,
|
|
395
|
+
name: args?.name,
|
|
396
|
+
headline: args?.headline,
|
|
397
|
+
clickUrl: args?.click_url,
|
|
398
|
+
thumbnailUrl: args?.thumbnail_url,
|
|
399
|
+
bodyText: args?.body_text,
|
|
400
|
+
callToAction: args?.call_to_action || "LEARN_MORE",
|
|
401
|
+
configuredStatus: args?.configured_status || "PAUSED",
|
|
402
|
+
creativeType: args?.creative_type || "IMAGE",
|
|
403
|
+
videoUrl: args?.video_url,
|
|
404
|
+
postUrl: args?.post_url,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
case "reddit_ads_update_ad": {
|
|
408
|
+
const updates = {};
|
|
409
|
+
if (args?.name != null)
|
|
410
|
+
updates.name = args.name;
|
|
411
|
+
if (args?.headline != null)
|
|
412
|
+
updates.headline = args.headline;
|
|
413
|
+
if (args?.click_url != null)
|
|
414
|
+
updates.click_url = args.click_url;
|
|
415
|
+
if (args?.configured_status != null)
|
|
416
|
+
updates.configured_status = args.configured_status;
|
|
417
|
+
if (args?.call_to_action != null)
|
|
418
|
+
updates.call_to_action = args.call_to_action;
|
|
419
|
+
return ok(await adsManager.updateAd(args?.ad_id, updates));
|
|
420
|
+
}
|
|
421
|
+
// ── Bulk status ──
|
|
422
|
+
case "reddit_ads_pause_items":
|
|
423
|
+
case "reddit_ads_enable_items": {
|
|
424
|
+
const statusValue = name === "reddit_ads_pause_items" ? "PAUSED" : "ACTIVE";
|
|
425
|
+
const itemType = args?.item_type;
|
|
426
|
+
const itemIds = args?.item_ids;
|
|
427
|
+
const updateFn = {
|
|
428
|
+
CAMPAIGN: (id, u) => adsManager.updateCampaign(id, u),
|
|
429
|
+
AD_GROUP: (id, u) => adsManager.updateAdGroup(id, u),
|
|
430
|
+
AD: (id, u) => adsManager.updateAd(id, u),
|
|
431
|
+
};
|
|
432
|
+
const fn = updateFn[itemType];
|
|
433
|
+
if (!fn)
|
|
434
|
+
return ok({ error: `Invalid item_type: ${itemType}. Use CAMPAIGN, AD_GROUP, or AD.` });
|
|
435
|
+
const results = [];
|
|
436
|
+
for (const itemId of itemIds) {
|
|
437
|
+
try {
|
|
438
|
+
const result = await fn(itemId, { configured_status: statusValue });
|
|
439
|
+
results.push({ id: itemId, status: statusValue.toLowerCase(), result });
|
|
440
|
+
}
|
|
441
|
+
catch (e) {
|
|
442
|
+
results.push({ id: itemId, status: "error", error: e.message });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return ok(results);
|
|
446
|
+
}
|
|
447
|
+
// ── Targeting ──
|
|
448
|
+
case "reddit_ads_search_subreddits":
|
|
449
|
+
return ok(await adsManager.searchSubreddits(args?.query));
|
|
450
|
+
case "reddit_ads_get_interest_categories":
|
|
451
|
+
return ok(await adsManager.getInterestCategories());
|
|
452
|
+
case "reddit_ads_search_geo_targets":
|
|
453
|
+
return ok(await adsManager.searchGeoTargets(args?.query));
|
|
454
|
+
default:
|
|
455
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch (rawError) {
|
|
459
|
+
const error = classifyError(rawError);
|
|
460
|
+
logger.error({ error_type: error.name, message: error.message }, "Tool call failed");
|
|
461
|
+
const response = {
|
|
462
|
+
error: true,
|
|
463
|
+
error_type: error.name,
|
|
464
|
+
message: error.message,
|
|
465
|
+
};
|
|
466
|
+
if (error instanceof RedditAdsAuthError) {
|
|
467
|
+
response.action_required = "Re-authenticate: refresh token may be expired. Run oauth_flow.py and update Keychain.";
|
|
468
|
+
}
|
|
469
|
+
else if (error instanceof RedditAdsRateLimitError) {
|
|
470
|
+
response.retry_after_ms = error.retryAfterMs;
|
|
471
|
+
response.action_required = `Rate limited. Retry after ${Math.ceil(error.retryAfterMs / 1000)} seconds.`;
|
|
472
|
+
}
|
|
473
|
+
else if (error instanceof RedditAdsServiceError) {
|
|
474
|
+
response.action_required = "Reddit API server error. This is transient - retry in a few minutes.";
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
response.details = rawError.stack;
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
481
|
+
isError: true,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
// Start server
|
|
486
|
+
async function main() {
|
|
487
|
+
try {
|
|
488
|
+
const me = await adsManager.getMe();
|
|
489
|
+
console.error(`[startup] Auth verified: logged in as ${me?.data?.username || "unknown"}`);
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
console.error(`[STARTUP WARNING] Auth check FAILED: ${err.message}`);
|
|
493
|
+
console.error(`[STARTUP WARNING] MCP will start but API calls may fail until auth is fixed.`);
|
|
494
|
+
}
|
|
495
|
+
const transport = new StdioServerTransport();
|
|
496
|
+
await server.connect(transport);
|
|
497
|
+
console.error("[startup] MCP Reddit Ads server running");
|
|
498
|
+
}
|
|
499
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { retry, circuitBreaker, wrap, handleAll, timeout, TimeoutStrategy, ExponentialBackoff, ConsecutiveBreaker, } from "cockatiel";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
// ============================================
|
|
4
|
+
// LOGGER
|
|
5
|
+
// ============================================
|
|
6
|
+
export const logger = pino({
|
|
7
|
+
level: process.env.LOG_LEVEL || "info",
|
|
8
|
+
...(process.env.NODE_ENV !== "test" && {
|
|
9
|
+
transport: {
|
|
10
|
+
target: "pino-pretty",
|
|
11
|
+
options: {
|
|
12
|
+
colorize: true,
|
|
13
|
+
singleLine: true,
|
|
14
|
+
translateTime: "SYS:standard",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
// ============================================
|
|
20
|
+
// SAFE RESPONSE (Response Size Limiting)
|
|
21
|
+
// ============================================
|
|
22
|
+
const MAX_RESPONSE_SIZE = 200_000; // 200KB
|
|
23
|
+
export function safeResponse(data, context) {
|
|
24
|
+
const jsonStr = JSON.stringify(data);
|
|
25
|
+
const sizeBytes = Buffer.byteLength(jsonStr, "utf-8");
|
|
26
|
+
if (sizeBytes > MAX_RESPONSE_SIZE) {
|
|
27
|
+
logger.warn({ sizeBytes, maxSize: MAX_RESPONSE_SIZE, context }, `Response exceeds size limit, truncating`);
|
|
28
|
+
if (Array.isArray(data)) {
|
|
29
|
+
const truncated = data.slice(0, Math.max(1, Math.floor(data.length * 0.5)));
|
|
30
|
+
return truncated;
|
|
31
|
+
}
|
|
32
|
+
if (typeof data === "object" && data !== null) {
|
|
33
|
+
const obj = data;
|
|
34
|
+
for (const key of ["items", "results", "data", "rows"]) {
|
|
35
|
+
if (Array.isArray(obj[key])) {
|
|
36
|
+
obj[key] = obj[key].slice(0, Math.max(1, Math.floor(obj[key].length * 0.5)));
|
|
37
|
+
return obj;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
// ============================================
|
|
45
|
+
// RETRY + CIRCUIT BREAKER + TIMEOUT
|
|
46
|
+
// ============================================
|
|
47
|
+
const backoff = new ExponentialBackoff({
|
|
48
|
+
initialDelay: 100,
|
|
49
|
+
maxDelay: 5_000,
|
|
50
|
+
});
|
|
51
|
+
const retryPolicy = retry(handleAll, {
|
|
52
|
+
maxAttempts: 3,
|
|
53
|
+
backoff,
|
|
54
|
+
});
|
|
55
|
+
const circuitBreakerPolicy = circuitBreaker(handleAll, {
|
|
56
|
+
halfOpenAfter: 60_000,
|
|
57
|
+
breaker: new ConsecutiveBreaker(5),
|
|
58
|
+
});
|
|
59
|
+
const timeoutPolicy = timeout(30_000, TimeoutStrategy.Cooperative);
|
|
60
|
+
const policy = wrap(timeoutPolicy, circuitBreakerPolicy, retryPolicy);
|
|
61
|
+
// ============================================
|
|
62
|
+
// WRAPPED API CALL WITH LOGGING
|
|
63
|
+
// ============================================
|
|
64
|
+
export async function withResilience(fn, operationName) {
|
|
65
|
+
try {
|
|
66
|
+
logger.debug({ operation: operationName }, "Starting API call");
|
|
67
|
+
const result = await policy.execute(() => fn());
|
|
68
|
+
logger.debug({ operation: operationName }, "API call succeeded");
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
73
|
+
logger.error({ operation: operationName, error: error.message, stack: error.stack }, "API call failed after retries");
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/tools.d.ts
ADDED
package/dist/tools.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
export const tools = [
|
|
2
|
+
{
|
|
3
|
+
name: "reddit_ads_get_client_context",
|
|
4
|
+
description: "Get a quick overview of the Reddit Ads account. Returns account info and active campaign count.",
|
|
5
|
+
inputSchema: {
|
|
6
|
+
type: "object",
|
|
7
|
+
properties: {
|
|
8
|
+
account_id: {
|
|
9
|
+
type: "string",
|
|
10
|
+
description: "Ad account ID. Uses default if not provided.",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "reddit_ads_get_accounts",
|
|
17
|
+
description: "List all Reddit ad accounts accessible to this user.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "reddit_ads_get_campaigns",
|
|
25
|
+
description: "List all campaigns for a Reddit ad account.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
account_id: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Ad account ID. Uses default if not provided.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "reddit_ads_get_ad_groups",
|
|
38
|
+
description: "List ad groups for a Reddit ad account, optionally filtered by campaign.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
43
|
+
campaign_id: { type: "string", description: "Optional - filter to a specific campaign." },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "reddit_ads_get_ads",
|
|
49
|
+
description: "List ads for a Reddit ad account, optionally filtered by ad group.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
54
|
+
ad_group_id: { type: "string", description: "Optional - filter to a specific ad group." },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "reddit_ads_get_performance_report",
|
|
60
|
+
description: "Get a performance report for Reddit ads. Spend values are in microcurrency (divide by 1,000,000 for dollars).",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
65
|
+
start_date: { type: "string", description: "Start date YYYY-MM-DD. Defaults to 7 days ago." },
|
|
66
|
+
end_date: { type: "string", description: "End date YYYY-MM-DD. Defaults to today." },
|
|
67
|
+
fields: {
|
|
68
|
+
type: "array",
|
|
69
|
+
items: { type: "string" },
|
|
70
|
+
description: "Metric fields. Defaults to impressions, clicks, spend, ctr, cpc, ecpm. Available: impressions, reach, clicks, spend, ecpm, ctr, cpc, video_watched_25/50/75/100_percent, conversion_purchase_clicks, etc.",
|
|
71
|
+
},
|
|
72
|
+
breakdowns: {
|
|
73
|
+
type: "array",
|
|
74
|
+
items: { type: "string" },
|
|
75
|
+
description: "Breakdown dimensions: ad_id, campaign_id, ad_group_id, date, country, region, community, placement, device_os.",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "reddit_ads_get_daily_performance",
|
|
82
|
+
description: "Get daily performance breakdown for the last N days. Convenience tool with date breakdown.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
87
|
+
days: { type: "number", description: "Number of days to look back. Defaults to 7." },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "reddit_ads_create_campaign",
|
|
93
|
+
description: "Create a new Reddit ad campaign. Created as PAUSED by default for safety.",
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: "object",
|
|
96
|
+
properties: {
|
|
97
|
+
name: { type: "string", description: "Campaign name." },
|
|
98
|
+
objective: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description: "Campaign objective: CONVERSIONS, TRAFFIC, AWARENESS, VIDEO_VIEWS, APP_INSTALLS, CONSIDERATION.",
|
|
101
|
+
},
|
|
102
|
+
daily_budget_dollars: { type: "number", description: "Daily budget in dollars (e.g. 50.00 for $50/day)." },
|
|
103
|
+
start_time: { type: "string", description: "Start datetime in ISO 8601 (e.g. 2026-03-17T00:00:00Z)." },
|
|
104
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
105
|
+
end_time: { type: "string", description: "Optional end datetime in ISO 8601." },
|
|
106
|
+
configured_status: { type: "string", description: "ACTIVE or PAUSED. Defaults to PAUSED." },
|
|
107
|
+
},
|
|
108
|
+
required: ["name", "objective", "daily_budget_dollars", "start_time"],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "reddit_ads_update_campaign",
|
|
113
|
+
description: "Update an existing Reddit campaign. Only pass fields you want to change.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
campaign_id: { type: "string", description: "The campaign ID to update." },
|
|
118
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
119
|
+
name: { type: "string", description: "New campaign name." },
|
|
120
|
+
daily_budget_dollars: { type: "number", description: "New daily budget in dollars." },
|
|
121
|
+
configured_status: { type: "string", description: "ACTIVE or PAUSED." },
|
|
122
|
+
end_time: { type: "string", description: "New end datetime in ISO 8601." },
|
|
123
|
+
},
|
|
124
|
+
required: ["campaign_id"],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "reddit_ads_create_ad_group",
|
|
129
|
+
description: "Create an ad group within a campaign. Created as PAUSED by default.",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
campaign_id: { type: "string", description: "Parent campaign ID." },
|
|
134
|
+
name: { type: "string", description: "Ad group name." },
|
|
135
|
+
bid_dollars: { type: "number", description: "Bid amount in dollars (e.g. 5.00 for $5 CPM)." },
|
|
136
|
+
start_time: { type: "string", description: "Start datetime in ISO 8601." },
|
|
137
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
138
|
+
end_time: { type: "string", description: "Optional end datetime." },
|
|
139
|
+
bid_strategy: { type: "string", description: "CPM, CPC, or CPV. Defaults to CPM." },
|
|
140
|
+
configured_status: { type: "string", description: "ACTIVE or PAUSED. Defaults to PAUSED." },
|
|
141
|
+
optimization_goal: { type: "string", description: "IMPRESSIONS, CLICKS, CONVERSIONS, etc." },
|
|
142
|
+
subreddit_names: { type: "array", items: { type: "string" }, description: "Subreddit names to target (without r/ prefix)." },
|
|
143
|
+
interest_ids: { type: "array", items: { type: "string" }, description: "Interest category IDs for targeting." },
|
|
144
|
+
geo_country_codes: { type: "array", items: { type: "string" }, description: 'Country codes (e.g. ["US", "CA"]).' },
|
|
145
|
+
device_types: { type: "array", items: { type: "string" }, description: "Device types: DESKTOP, MOBILE, TABLET." },
|
|
146
|
+
},
|
|
147
|
+
required: ["campaign_id", "name", "bid_dollars", "start_time"],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "reddit_ads_update_ad_group",
|
|
152
|
+
description: "Update an existing ad group. Only pass fields you want to change.",
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
ad_group_id: { type: "string", description: "The ad group ID to update." },
|
|
157
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
158
|
+
name: { type: "string", description: "New ad group name." },
|
|
159
|
+
bid_dollars: { type: "number", description: "New bid in dollars." },
|
|
160
|
+
configured_status: { type: "string", description: "ACTIVE or PAUSED." },
|
|
161
|
+
end_time: { type: "string", description: "New end datetime in ISO 8601." },
|
|
162
|
+
},
|
|
163
|
+
required: ["ad_group_id"],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "reddit_ads_create_ad",
|
|
168
|
+
description: "Create a new Reddit ad within an ad group. Created as PAUSED by default.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: "object",
|
|
171
|
+
properties: {
|
|
172
|
+
ad_group_id: { type: "string", description: "Parent ad group ID." },
|
|
173
|
+
name: { type: "string", description: "Internal ad name." },
|
|
174
|
+
headline: { type: "string", description: "Ad headline (max 300 chars)." },
|
|
175
|
+
click_url: { type: "string", description: "Landing page URL." },
|
|
176
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
177
|
+
thumbnail_url: { type: "string", description: "URL to image asset (for image ads)." },
|
|
178
|
+
body_text: { type: "string", description: "Optional body text." },
|
|
179
|
+
call_to_action: { type: "string", description: "CTA: SIGN_UP, LEARN_MORE, SHOP_NOW, INSTALL, GET_QUOTE, CONTACT_US, DOWNLOAD." },
|
|
180
|
+
configured_status: { type: "string", description: "ACTIVE or PAUSED. Defaults to PAUSED." },
|
|
181
|
+
creative_type: { type: "string", description: "IMAGE, VIDEO, CAROUSEL, or TEXT. Defaults to IMAGE." },
|
|
182
|
+
video_url: { type: "string", description: "URL to video asset (for video ads)." },
|
|
183
|
+
post_url: { type: "string", description: "URL to existing Reddit post to promote." },
|
|
184
|
+
},
|
|
185
|
+
required: ["ad_group_id", "name", "headline", "click_url"],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "reddit_ads_update_ad",
|
|
190
|
+
description: "Update an existing ad. Only pass fields you want to change.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
ad_id: { type: "string", description: "The ad ID to update." },
|
|
195
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
196
|
+
name: { type: "string", description: "New ad name." },
|
|
197
|
+
headline: { type: "string", description: "New headline text." },
|
|
198
|
+
click_url: { type: "string", description: "New landing page URL." },
|
|
199
|
+
configured_status: { type: "string", description: "ACTIVE or PAUSED." },
|
|
200
|
+
call_to_action: { type: "string", description: "New CTA button text." },
|
|
201
|
+
},
|
|
202
|
+
required: ["ad_id"],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "reddit_ads_pause_items",
|
|
207
|
+
description: "Pause one or more campaigns, ad groups, or ads.",
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: "object",
|
|
210
|
+
properties: {
|
|
211
|
+
item_type: { type: "string", description: "Type: CAMPAIGN, AD_GROUP, or AD." },
|
|
212
|
+
item_ids: { type: "array", items: { type: "string" }, description: "List of IDs to pause." },
|
|
213
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
214
|
+
},
|
|
215
|
+
required: ["item_type", "item_ids"],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "reddit_ads_enable_items",
|
|
220
|
+
description: "Enable (unpause) one or more campaigns, ad groups, or ads.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {
|
|
224
|
+
item_type: { type: "string", description: "Type: CAMPAIGN, AD_GROUP, or AD." },
|
|
225
|
+
item_ids: { type: "array", items: { type: "string" }, description: "List of IDs to enable." },
|
|
226
|
+
account_id: { type: "string", description: "Ad account ID. Uses default if not provided." },
|
|
227
|
+
},
|
|
228
|
+
required: ["item_type", "item_ids"],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "reddit_ads_search_subreddits",
|
|
233
|
+
description: "Search for subreddits to use as targeting in ad groups.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: "object",
|
|
236
|
+
properties: {
|
|
237
|
+
query: { type: "string", description: 'Search query (e.g. "ecommerce", "fulfillment", "shopify").' },
|
|
238
|
+
},
|
|
239
|
+
required: ["query"],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "reddit_ads_get_interest_categories",
|
|
244
|
+
description: "Get available interest categories for ad group targeting.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "reddit_ads_search_geo_targets",
|
|
252
|
+
description: "Search geographic targeting options (countries, regions).",
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: "object",
|
|
255
|
+
properties: {
|
|
256
|
+
query: { type: "string", description: 'Search query (e.g. "United States", "California").' },
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-reddit-ads",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Reddit Ads API v3 with campaign, ad group, ad management, and performance reporting.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"config.example.json"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"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()}))\"",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"dev": "tsx src/index.ts",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"author": "drak-marketing",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^0.5.0",
|
|
33
|
+
"cockatiel": "^3.2.1",
|
|
34
|
+
"pino": "^8.21.0",
|
|
35
|
+
"pino-pretty": "^13.1.3"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^20.10.0",
|
|
39
|
+
"tsx": "^4.7.0",
|
|
40
|
+
"typescript": "^5.3.0",
|
|
41
|
+
"vitest": "^4.0.18"
|
|
42
|
+
}
|
|
43
|
+
}
|