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 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"}
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,3 @@
1
+ export declare const logger: import("pino").Logger<never>;
2
+ export declare function safeResponse<T>(data: T, context: string): T;
3
+ export declare function withResilience<T>(fn: () => Promise<T>, operationName: string): Promise<T>;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare const tools: Tool[];
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
+ }