reddit-mcp-server 1.4.7 → 1.5.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/dist/index.js CHANGED
@@ -3,9 +3,162 @@ import dotenv from "dotenv";
3
3
  import { FastMCP } from "fastmcp";
4
4
  import { Left, Option, Right, Try } from "functype";
5
5
  import { z } from "zod";
6
+ //#region src/client/errors.ts
7
+ /**
8
+ * Typed error channel for the Reddit client.
9
+ *
10
+ * The client's methods are the imperative-to-functional boundary: each captures throws
11
+ * (network, HTTP, JSON parsing, validation, deliberate domain errors) inside a `Try` and
12
+ * converts to a typed `Either<RedditError, T>`. Modelling the error as a discriminated ADT
13
+ * — rather than a bare `Error` — makes the failure contract explicit at the type level, so
14
+ * callers can reason about (and branch on) what actually went wrong without string-matching.
15
+ *
16
+ * `classifyRedditError` is TOTAL and never re-throws — every captured `Error` maps to a
17
+ * variant — which is what makes the migration behavior-preserving: an error that was a
18
+ * graceful `Left` before is still a graceful `Left` after, just with a richer type.
19
+ *
20
+ * Two-tier behavior, to preserve the exact messages of the original try/catch code:
21
+ * - A *deliberate* typed throw (HttpError, NotFoundError, …) already carries its final
22
+ * message, so it passes through unchanged.
23
+ * - An *unexpected* generic error (fetch/JSON/orThrow) is wrapped as UnknownError, with the
24
+ * optional `context` prefix — present for read methods (which prefixed in their catch),
25
+ * absent for write methods (which returned the raw message).
26
+ */
27
+ var RedditErrorBase = class extends Error {};
28
+ /** A non-ok HTTP response from the Reddit API. Carries the status for caller branching. */
29
+ var HttpError = class extends RedditErrorBase {
30
+ status;
31
+ _tag = "HttpError";
32
+ constructor(status, message) {
33
+ super(message);
34
+ this.status = status;
35
+ this.name = "HttpError";
36
+ }
37
+ };
38
+ /** A write operation was attempted without the required user credentials / in a wrong mode. */
39
+ var NotAuthenticatedError = class extends RedditErrorBase {
40
+ _tag = "NotAuthenticatedError";
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = "NotAuthenticatedError";
44
+ }
45
+ };
46
+ /** Reddit accepted the request but returned errors in its JSON envelope (or an unusable body). */
47
+ var ApiError = class extends RedditErrorBase {
48
+ _tag = "ApiError";
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "ApiError";
52
+ }
53
+ };
54
+ /** The requested post/entity does not exist or is not accessible. */
55
+ var NotFoundError = class extends RedditErrorBase {
56
+ _tag = "NotFoundError";
57
+ constructor(message) {
58
+ super(message);
59
+ this.name = "NotFoundError";
60
+ }
61
+ };
62
+ /** Client-side input or safety-policy rejection (invalid sort, duplicate-content guard). */
63
+ var ValidationError = class extends RedditErrorBase {
64
+ _tag = "ValidationError";
65
+ constructor(message) {
66
+ super(message);
67
+ this.name = "ValidationError";
68
+ }
69
+ };
70
+ /** Any failure that is not a recognized category: network, JSON parsing, unexpected throws. */
71
+ var UnknownError = class extends RedditErrorBase {
72
+ cause;
73
+ _tag = "UnknownError";
74
+ constructor(message, cause) {
75
+ super(message);
76
+ this.cause = cause;
77
+ this.name = "UnknownError";
78
+ }
79
+ };
80
+ function isRedditError(error) {
81
+ return error instanceof RedditErrorBase;
82
+ }
83
+ /**
84
+ * Total classifier from a captured `Error` to a `RedditError`. Deliberate typed throws pass
85
+ * through unchanged; everything else becomes an `UnknownError`, prefixed with `context` when
86
+ * provided so the observable message text matches the previous try/catch-based wrapping.
87
+ */
88
+ function classifyRedditError(error, context) {
89
+ if (isRedditError(error)) return error;
90
+ return new UnknownError(Option(context).fold(() => error.message, (ctx) => `${ctx}: ${error.message}`), error);
91
+ }
92
+ //#endregion
93
+ //#region src/client/response-cache.ts
94
+ const SECOND = 1e3;
95
+ var ResponseCache = class {
96
+ maxBytes;
97
+ now;
98
+ entries = /* @__PURE__ */ new Map();
99
+ currentBytes = 0;
100
+ constructor(options) {
101
+ this.maxBytes = options.maxBytes;
102
+ this.now = options.now ?? Date.now;
103
+ }
104
+ /** Adaptive TTL (in milliseconds) for a given request URL. */
105
+ ttlFor(url) {
106
+ if (/\/(hot|new|rising)\.json/.test(url)) return 60 * SECOND;
107
+ if (/\/(top|controversial)\.json/.test(url) || /\/search\.json/.test(url) || /\/about\.json/.test(url)) return 300 * SECOND;
108
+ if (/\/comments\//.test(url)) return 60 * SECOND;
109
+ return 120 * SECOND;
110
+ }
111
+ get(url) {
112
+ const entry = this.entries.get(url);
113
+ if (entry === void 0) return;
114
+ if (this.now() >= entry.expiresAt) {
115
+ this.entries.delete(url);
116
+ this.currentBytes -= entry.bytes;
117
+ return;
118
+ }
119
+ this.entries.delete(url);
120
+ this.entries.set(url, entry);
121
+ return {
122
+ body: entry.body,
123
+ status: entry.status
124
+ };
125
+ }
126
+ set(url, body, status) {
127
+ const bytes = Buffer.byteLength(body, "utf8");
128
+ if (bytes > this.maxBytes) return;
129
+ const existing = this.entries.get(url);
130
+ if (existing !== void 0) {
131
+ this.entries.delete(url);
132
+ this.currentBytes -= existing.bytes;
133
+ }
134
+ this.entries.set(url, {
135
+ body,
136
+ status,
137
+ expiresAt: this.now() + this.ttlFor(url),
138
+ bytes
139
+ });
140
+ this.currentBytes += bytes;
141
+ this.evictUntilWithinBudget();
142
+ }
143
+ evictUntilWithinBudget() {
144
+ while (this.currentBytes > this.maxBytes) {
145
+ const oldestKey = this.entries.keys().next().value;
146
+ if (oldestKey === void 0) return;
147
+ const oldest = this.entries.get(oldestKey);
148
+ this.entries.delete(oldestKey);
149
+ if (oldest !== void 0) this.currentBytes -= oldest.bytes;
150
+ }
151
+ }
152
+ };
153
+ //#endregion
6
154
  //#region src/client/reddit-client.ts
7
- function toError(error) {
8
- return error instanceof Error ? error : new Error(String(error));
155
+ function listingCursor(data) {
156
+ const after = typeof data.after === "string" ? { after: data.after } : {};
157
+ const before = typeof data.before === "string" ? { before: data.before } : {};
158
+ return {
159
+ ...after,
160
+ ...before
161
+ };
9
162
  }
10
163
  function parsePostData(post) {
11
164
  return {
@@ -38,12 +191,15 @@ var RedditClient = class {
38
191
  hasCredentials;
39
192
  safeMode;
40
193
  botDisclosure;
194
+ cache;
195
+ retry;
41
196
  accessToken;
42
197
  tokenExpiry = 0;
43
198
  authenticated = false;
44
199
  lastWriteTime = 0;
45
200
  recentContentRecords = [];
46
201
  constructor(config) {
202
+ var _config$cache;
47
203
  this.clientId = config.clientId;
48
204
  this.clientSecret = config.clientSecret;
49
205
  this.userAgent = config.userAgent;
@@ -63,6 +219,12 @@ var RedditClient = class {
63
219
  enabled: false,
64
220
  footer: ""
65
221
  };
222
+ this.cache = ((_config$cache = config.cache) === null || _config$cache === void 0 ? void 0 : _config$cache.enabled) === true ? new ResponseCache({ maxBytes: config.cache.maxBytes }) : void 0;
223
+ this.retry = config.retry ?? {
224
+ maxRetries: 3,
225
+ baseDelayMs: 1e3,
226
+ maxDelayMs: 6e4
227
+ };
66
228
  }
67
229
  determineBaseUrl() {
68
230
  switch (this.authMode) {
@@ -72,34 +234,33 @@ var RedditClient = class {
72
234
  }
73
235
  }
74
236
  async makeRequest(path, options = {}) {
75
- try {
237
+ return (await Try.async(async () => {
238
+ const url = `${this.baseUrl}${path}`;
239
+ const method = (options.method ?? "GET").toUpperCase();
240
+ const cacheable = this.cache !== void 0 && method === "GET";
241
+ if (cacheable) {
242
+ const cached = this.cache.get(url);
243
+ if (cached !== void 0) return new Response(cached.body, { status: cached.status });
244
+ }
76
245
  const requiresAuth = this.authMode === "authenticated" || this.authMode === "auto" && this.hasCredentials;
77
246
  if (requiresAuth && (Date.now() >= this.tokenExpiry || !this.authenticated)) (await this.authenticate()).orThrow();
78
- const url = `${this.baseUrl}${path}`;
79
247
  const headers = {
80
248
  "User-Agent": this.userAgent,
81
249
  ...options.headers
82
250
  };
83
251
  if (requiresAuth && this.accessToken !== void 0) headers["Authorization"] = `Bearer ${this.accessToken}`;
84
- const response = await fetch(url, {
85
- ...options,
86
- headers
87
- });
88
- if (response.status === 401 && this.authenticated) {
89
- (await this.authenticate()).orThrow();
90
- const retryHeaders = {
91
- ...headers,
92
- Authorization: `Bearer ${this.accessToken}`
93
- };
94
- return Right(await fetch(url, {
95
- ...options,
96
- headers: retryHeaders
97
- }));
252
+ const first = await this.fetchWithRetry(url, options, headers, path, 0);
253
+ const response = first.status === 401 && this.authenticated ? await this.fetchWithRetry(url, options, {
254
+ ...headers,
255
+ Authorization: await this.reauthorize()
256
+ }, path, 0) : first;
257
+ if (cacheable && response.ok) {
258
+ const text = await response.text();
259
+ this.cache.set(url, text, response.status);
260
+ return new Response(text, { status: response.status });
98
261
  }
99
- return Right(response);
100
- } catch (error) {
101
- return Left(toError(error));
102
- }
262
+ return response;
263
+ })).toEither((error) => error);
103
264
  }
104
265
  async authenticate() {
105
266
  if (this.authMode === "anonymous") {
@@ -111,9 +272,9 @@ var RedditClient = class {
111
272
  this.authenticated = false;
112
273
  return Right(void 0);
113
274
  }
114
- try {
275
+ return (await Try.async(async () => {
115
276
  const now = Date.now();
116
- if (this.accessToken !== void 0 && now < this.tokenExpiry) return Right(void 0);
277
+ if (this.accessToken !== void 0 && now < this.tokenExpiry) return;
117
278
  const authUrl = "https://www.reddit.com/api/v1/access_token";
118
279
  const authData = new URLSearchParams();
119
280
  const { username } = this;
@@ -135,16 +296,13 @@ var RedditClient = class {
135
296
  });
136
297
  if (!response.ok) {
137
298
  const statusText = response.statusText !== "" ? response.statusText : "Unknown Error";
138
- return Left(/* @__PURE__ */ new Error(`Authentication failed: ${response.status} ${statusText}`));
299
+ throw new Error(`Authentication failed: ${response.status} ${statusText}`);
139
300
  }
140
301
  const data = await response.json();
141
302
  this.accessToken = data.access_token;
142
303
  this.tokenExpiry = now + data.expires_in * 1e3;
143
304
  this.authenticated = true;
144
- return Right(void 0);
145
- } catch (error) {
146
- return Left(toError(error));
147
- }
305
+ })).toEither((error) => error);
148
306
  }
149
307
  async checkAuthentication() {
150
308
  if (!this.authenticated) return (await this.authenticate()).isRight();
@@ -152,8 +310,8 @@ var RedditClient = class {
152
310
  }
153
311
  validateWriteAccess() {
154
312
  if (this.username === void 0 || this.password === void 0) {
155
- if (this.authMode === "anonymous") throw new Error("Write operations not available in anonymous mode. Set REDDIT_USERNAME, REDDIT_PASSWORD and use 'auto' or 'authenticated' mode.");
156
- throw new Error("Write operations require REDDIT_USERNAME and REDDIT_PASSWORD");
313
+ if (this.authMode === "anonymous") throw new NotAuthenticatedError("Write operations not available in anonymous mode. Set REDDIT_USERNAME, REDDIT_PASSWORD and use 'auto' or 'authenticated' mode.");
314
+ throw new NotAuthenticatedError("Write operations require REDDIT_USERNAME and REDDIT_PASSWORD");
157
315
  }
158
316
  }
159
317
  async enforceWriteRateLimit() {
@@ -174,8 +332,8 @@ var RedditClient = class {
174
332
  const hash = this.hashContent(content);
175
333
  const duplicate = this.recentContentRecords.find((record) => record.hash === hash);
176
334
  if (duplicate !== void 0) {
177
- if (subreddit !== void 0 && duplicate.subreddit !== "" && subreddit !== duplicate.subreddit) throw new Error("Cross-subreddit duplicate detected. Reddit's Responsible Builder Policy prohibits posting identical or substantially similar content across multiple subreddits. Please create unique content for each subreddit.");
178
- throw new Error("Duplicate content detected. Reddit's spam filter may ban your account for posting identical content. Please modify your content and try again.");
335
+ if (subreddit !== void 0 && duplicate.subreddit !== "" && subreddit !== duplicate.subreddit) throw new ValidationError("Cross-subreddit duplicate detected. Reddit's Responsible Builder Policy prohibits posting identical or substantially similar content across multiple subreddits. Please create unique content for each subreddit.");
336
+ throw new ValidationError("Duplicate content detected. Reddit's spam filter may ban your account for posting identical content. Please modify your content and try again.");
179
337
  }
180
338
  this.recentContentRecords.push({
181
339
  hash,
@@ -184,16 +342,49 @@ var RedditClient = class {
184
342
  });
185
343
  this.recentContentRecords = this.recentContentRecords.slice(-this.safeMode.maxRecentHashes);
186
344
  }
345
+ async reauthorize() {
346
+ (await this.authenticate()).orThrow();
347
+ return `Bearer ${this.accessToken}`;
348
+ }
349
+ async fetchWithRetry(url, options, headers, path, attempt) {
350
+ const response = await fetch(url, {
351
+ ...options,
352
+ headers
353
+ });
354
+ if (response.status !== 429 || attempt >= this.retry.maxRetries) return response;
355
+ const wait = this.retryAfterMs(response).fold(() => Math.min(this.retry.baseDelayMs * 2 ** attempt, this.retry.maxDelayMs), (ms) => ms);
356
+ if (wait > this.retry.maxDelayMs) return response;
357
+ console.error(`[RateLimit] 429 from ${path} — retry ${attempt + 1}/${this.retry.maxRetries} in ${wait}ms`);
358
+ await new Promise((resolve) => setTimeout(resolve, wait));
359
+ return this.fetchWithRetry(url, options, headers, path, attempt + 1);
360
+ }
361
+ retryAfterMs(response) {
362
+ const { headers } = response;
363
+ const retryAfter = headers.get("retry-after");
364
+ if (retryAfter !== null && retryAfter !== "") {
365
+ const seconds = Number(retryAfter);
366
+ if (!Number.isNaN(seconds)) return Option(seconds * 1e3);
367
+ const when = Date.parse(retryAfter);
368
+ if (!Number.isNaN(when)) return Option(Math.max(0, when - Date.now()));
369
+ }
370
+ const reset = headers.get("x-ratelimit-reset");
371
+ if (reset !== null && reset !== "") {
372
+ const seconds = Number(reset);
373
+ if (!Number.isNaN(seconds)) return Option(seconds * 1e3);
374
+ }
375
+ return Option.none();
376
+ }
187
377
  appendBotDisclosure(content) {
188
378
  if (!this.botDisclosure.enabled || this.botDisclosure.footer === "") return content;
189
379
  return `${content}${this.botDisclosure.footer}`;
190
380
  }
191
381
  async getUser(username) {
192
- try {
382
+ const context = `Failed to get user info for ${username}`;
383
+ return (await Try.async(async () => {
193
384
  const response = (await this.makeRequest(`/user/${username}/about.json`)).orThrow();
194
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get user info for ${username}: HTTP ${response.status}`));
385
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
195
386
  const { data } = await response.json();
196
- return Right({
387
+ return {
197
388
  name: data.name,
198
389
  id: data.id,
199
390
  commentKarma: data.comment_karma,
@@ -204,17 +395,79 @@ var RedditClient = class {
204
395
  isEmployee: data.is_employee,
205
396
  createdUtc: data.created_utc,
206
397
  profileUrl: `https://reddit.com/user/${data.name}`
207
- });
208
- } catch (error) {
209
- return Left(/* @__PURE__ */ new Error(`Failed to get user info for ${username}: ${toError(error).message}`));
210
- }
398
+ };
399
+ })).toEither((error) => classifyRedditError(error, context));
400
+ }
401
+ async getUserContent(path, context) {
402
+ return (await Try.async(async () => {
403
+ const response = (await this.makeRequest(path)).orThrow();
404
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
405
+ const json = await response.json();
406
+ return {
407
+ posts: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
408
+ comments: json.data.children.filter((child) => child.kind === "t1").map((child) => {
409
+ const comment = child.data;
410
+ return {
411
+ id: comment.id,
412
+ author: comment.author,
413
+ body: comment.body ?? "",
414
+ score: comment.score,
415
+ controversiality: comment.controversiality,
416
+ subreddit: comment.subreddit,
417
+ submissionTitle: comment.link_title ?? "",
418
+ createdUtc: comment.created_utc,
419
+ edited: Boolean(comment.edited),
420
+ isSubmitter: comment.is_submitter,
421
+ permalink: comment.permalink,
422
+ parentId: comment.parent_id
423
+ };
424
+ }),
425
+ ...listingCursor(json.data)
426
+ };
427
+ })).toEither((error) => classifyRedditError(error, context));
428
+ }
429
+ async getMyOverview(options = {}) {
430
+ if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your overview requires REDDIT_USERNAME"));
431
+ const { limit = 25, after } = options;
432
+ const params = new URLSearchParams({ limit: limit.toString() });
433
+ if (after !== void 0) params.set("after", after);
434
+ return this.getUserContent(`/user/${this.username}/overview.json?${params}`, "Failed to get your overview");
435
+ }
436
+ async getMySaved(options = {}) {
437
+ if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching saved content requires REDDIT_USERNAME"));
438
+ const { limit = 25, after } = options;
439
+ const params = new URLSearchParams({ limit: limit.toString() });
440
+ if (after !== void 0) params.set("after", after);
441
+ return this.getUserContent(`/user/${this.username}/saved.json?${params}`, "Failed to get saved content");
442
+ }
443
+ async getMe() {
444
+ if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your account requires REDDIT_USERNAME"));
445
+ const context = "Failed to get authenticated user info";
446
+ return (await Try.async(async () => {
447
+ const response = (await this.makeRequest("/api/v1/me")).orThrow();
448
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
449
+ const data = await response.json();
450
+ return {
451
+ name: data.name,
452
+ id: data.id,
453
+ commentKarma: data.comment_karma,
454
+ linkKarma: data.link_karma,
455
+ totalKarma: data.total_karma ?? data.comment_karma + data.link_karma,
456
+ isMod: data.is_mod,
457
+ isGold: data.is_gold,
458
+ isEmployee: data.is_employee,
459
+ createdUtc: data.created_utc,
460
+ profileUrl: `https://reddit.com/user/${data.name}`
461
+ };
462
+ })).toEither((error) => classifyRedditError(error, context));
211
463
  }
212
464
  async getSubredditInfo(subredditName) {
213
- try {
465
+ const context = `Failed to get subreddit info for ${subredditName}`;
466
+ return (await Try.async(async () => {
214
467
  const response = (await this.makeRequest(`/r/${subredditName}/about.json`)).orThrow();
215
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get subreddit info for ${subredditName}: HTTP ${response.status}`));
468
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
216
469
  const { data } = await response.json();
217
- return Right({
470
+ return {
218
471
  displayName: data.display_name,
219
472
  title: data.title,
220
473
  description: data.description,
@@ -225,50 +478,103 @@ var RedditClient = class {
225
478
  over18: data.over18,
226
479
  subredditType: data.subreddit_type,
227
480
  url: data.url
228
- });
229
- } catch (error) {
230
- return Left(/* @__PURE__ */ new Error(`Failed to get subreddit info for ${subredditName}: ${toError(error).message}`));
231
- }
481
+ };
482
+ })).toEither((error) => classifyRedditError(error, context));
483
+ }
484
+ async getSubredditRules(subreddit) {
485
+ const context = `Failed to get rules for r/${subreddit}`;
486
+ return (await Try.async(async () => {
487
+ const response = (await this.makeRequest(`/r/${subreddit}/about/rules.json`)).orThrow();
488
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
489
+ return (await response.json()).rules.map((rule) => ({
490
+ shortName: rule.short_name,
491
+ description: rule.description,
492
+ kind: rule.kind,
493
+ violationReason: rule.violation_reason,
494
+ priority: rule.priority,
495
+ createdUtc: rule.created_utc
496
+ }));
497
+ })).toEither((error) => classifyRedditError(error, context));
498
+ }
499
+ async getPostFlairs(subreddit) {
500
+ const context = `Failed to get post flairs for r/${subreddit}`;
501
+ return (await Try.async(async () => {
502
+ const response = (await this.makeRequest(`/r/${subreddit}/api/link_flair_v2.json`)).orThrow();
503
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
504
+ return (await response.json()).map((flair) => ({
505
+ id: flair.id,
506
+ text: flair.text,
507
+ type: flair.type,
508
+ textEditable: flair.text_editable
509
+ }));
510
+ })).toEither((error) => classifyRedditError(error, context));
232
511
  }
233
- async getTopPosts(subreddit, timeFilter = "week", limit = 10) {
512
+ async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
234
513
  const endpoint = subreddit !== "" ? `/r/${subreddit}/top.json` : "/top.json";
235
514
  const params = new URLSearchParams({
236
515
  t: timeFilter,
237
516
  limit: limit.toString()
238
517
  });
239
- try {
518
+ if (after !== void 0) params.set("after", after);
519
+ const context = `Failed to get top posts for ${subreddit !== "" ? subreddit : "home"}`;
520
+ return (await Try.async(async () => {
240
521
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
241
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get top posts: HTTP ${response.status}`));
242
- return Right((await response.json()).data.children.map((child) => parsePostData(child.data)));
243
- } catch (error) {
244
- return Left(/* @__PURE__ */ new Error(`Failed to get top posts for ${subreddit !== "" ? subreddit : "home"}: ${toError(error).message}`));
245
- }
522
+ if (!response.ok) throw new HttpError(response.status, `Failed to get top posts: HTTP ${response.status}`);
523
+ const json = await response.json();
524
+ return {
525
+ items: json.data.children.map((child) => parsePostData(child.data)),
526
+ ...listingCursor(json.data)
527
+ };
528
+ })).toEither((error) => classifyRedditError(error, context));
529
+ }
530
+ async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10, after) {
531
+ const validSorts = [
532
+ "hot",
533
+ "new",
534
+ "top",
535
+ "rising",
536
+ "controversial"
537
+ ];
538
+ if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
539
+ const endpoint = subreddit !== "" ? `/r/${subreddit}/${sort}.json` : `/${sort}.json`;
540
+ const params = new URLSearchParams({ limit: limit.toString() });
541
+ if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
542
+ if (after !== void 0) params.set("after", after);
543
+ const home = subreddit !== "" ? subreddit : "home";
544
+ const context = `Failed to browse r/${home} (${sort})`;
545
+ return (await Try.async(async () => {
546
+ const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
547
+ if (!response.ok) throw new HttpError(response.status, `Failed to browse r/${home}: HTTP ${response.status}`);
548
+ const json = await response.json();
549
+ return {
550
+ items: json.data.children.map((child) => parsePostData(child.data)),
551
+ ...listingCursor(json.data)
552
+ };
553
+ })).toEither((error) => classifyRedditError(error, context));
246
554
  }
247
555
  async getPost(postId, subreddit) {
248
556
  const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${postId}`, (sr) => `/r/${sr}/comments/${postId}.json`);
249
- try {
557
+ const context = `Failed to get post with ID ${postId}`;
558
+ return (await Try.async(async () => {
250
559
  const response = (await this.makeRequest(endpoint)).orThrow();
251
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get post with ID ${postId}: HTTP ${response.status}`));
252
- if (subreddit !== void 0) return Right(parsePostData((await response.json())[0].data.children[0].data));
560
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
561
+ if (subreddit !== void 0) return parsePostData((await response.json())[0].data.children[0].data);
253
562
  const json = await response.json();
254
- if (json.data.children.length === 0) return Left(/* @__PURE__ */ new Error(`Post with ID ${postId} not found`));
255
- return Right(parsePostData(json.data.children[0].data));
256
- } catch (error) {
257
- return Left(/* @__PURE__ */ new Error(`Failed to get post with ID ${postId}: ${toError(error).message}`));
258
- }
563
+ if (json.data.children.length === 0) throw new NotFoundError(`Post with ID ${postId} not found`);
564
+ return parsePostData(json.data.children[0].data);
565
+ })).toEither((error) => classifyRedditError(error, context));
259
566
  }
260
567
  async getTrendingSubreddits(limit = 5) {
261
568
  const params = new URLSearchParams({ limit: limit.toString() });
262
- try {
569
+ const context = `Failed to get trending subreddits`;
570
+ return (await Try.async(async () => {
263
571
  const response = (await this.makeRequest(`/subreddits/popular.json?${params}`)).orThrow();
264
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get trending subreddits: HTTP ${response.status}`));
265
- return Right((await response.json()).data.children.map((child) => child.data.display_name));
266
- } catch (error) {
267
- return Left(/* @__PURE__ */ new Error(`Failed to get trending subreddits: ${toError(error).message}`));
268
- }
572
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
573
+ return (await response.json()).data.children.map((child) => child.data.display_name);
574
+ })).toEither((error) => classifyRedditError(error, context));
269
575
  }
270
- async createPost(subreddit, title, content, isSelf = true) {
271
- try {
576
+ async createPost(subreddit, title, content, isSelf = true, flairId, flairText) {
577
+ return (await Try.async(async () => {
272
578
  var _json$json$data, _json$json$data2;
273
579
  this.validateWriteAccess();
274
580
  await this.enforceWriteRateLimit();
@@ -281,35 +587,30 @@ var RedditClient = class {
281
587
  params.append("title", title);
282
588
  params.append(isSelf ? "text" : "url", finalContent);
283
589
  params.append("api_type", "json");
590
+ if (flairId !== void 0) params.append("flair_id", flairId);
591
+ if (flairText !== void 0) params.append("flair_text", flairText);
284
592
  const response = (await this.makeRequest("/api/submit", {
285
593
  method: "POST",
286
594
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
287
595
  body: params.toString()
288
596
  })).orThrow();
289
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to create post: HTTP ${response.status}`));
597
+ if (!response.ok) throw new HttpError(response.status, `Failed to create post: HTTP ${response.status}`);
290
598
  const json = await response.json();
291
- if (json.json.errors !== void 0 && json.json.errors.length > 0) {
292
- const errors = json.json.errors.map((e) => e[1]).join(", ");
293
- return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
294
- }
599
+ if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
295
600
  const postId = ((_json$json$data = json.json.data) === null || _json$json$data === void 0 ? void 0 : _json$json$data.id) ?? ((_json$json$data2 = json.json.data) === null || _json$json$data2 === void 0 || (_json$json$data2 = _json$json$data2.name) === null || _json$json$data2 === void 0 ? void 0 : _json$json$data2.replace("t3_", ""));
296
- if (postId === void 0) return Left(/* @__PURE__ */ new Error("No post ID returned from Reddit"));
297
- return this.getPost(postId, subreddit);
298
- } catch (error) {
299
- return Left(toError(error));
300
- }
601
+ if (postId === void 0) throw new ApiError("No post ID returned from Reddit");
602
+ return (await this.getPost(postId, subreddit)).orThrow();
603
+ })).toEither((error) => classifyRedditError(error));
301
604
  }
302
605
  async checkPostExists(postId) {
303
- try {
606
+ return (await Try.async(async () => {
304
607
  const response = (await this.makeRequest(`/api/info.json?id=t3_${postId}`)).orThrow();
305
608
  if (!response.ok) return false;
306
609
  return (await response.json()).data.children.length > 0;
307
- } catch {
308
- return false;
309
- }
610
+ })).orElse(false);
310
611
  }
311
612
  async replyToPost(postId, content) {
312
- try {
613
+ return (await Try.async(async () => {
313
614
  var _json$json$data3;
314
615
  this.validateWriteAccess();
315
616
  await this.enforceWriteRateLimit();
@@ -317,7 +618,7 @@ var RedditClient = class {
317
618
  const finalContent = this.appendBotDisclosure(content);
318
619
  const fullThingId = postId.startsWith("t3_") || postId.startsWith("t1_") ? postId : `t3_${postId}`;
319
620
  if (!postId.startsWith("t1_")) {
320
- if (!await this.checkPostExists(postId.replace(/^t3_/, ""))) return Left(/* @__PURE__ */ new Error(`Post with ID ${postId} does not exist or is not accessible`));
621
+ if (!await this.checkPostExists(postId.replace(/^t3_/, ""))) throw new NotFoundError(`Post with ID ${postId} does not exist or is not accessible`);
321
622
  }
322
623
  const params = new URLSearchParams();
323
624
  params.append("thing_id", fullThingId);
@@ -328,12 +629,12 @@ var RedditClient = class {
328
629
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
329
630
  body: params.toString()
330
631
  })).orThrow();
331
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to reply: HTTP ${response.status}`));
632
+ if (!response.ok) throw new HttpError(response.status, `Failed to reply: HTTP ${response.status}`);
332
633
  const json = await response.json();
333
634
  if (((_json$json$data3 = json.json.data) === null || _json$json$data3 === void 0 ? void 0 : _json$json$data3.things) !== void 0 && json.json.data.things.length > 0) {
334
635
  const commentData = json.json.data.things[0].data;
335
636
  const author = this.username ?? "[unknown]";
336
- return Right({
637
+ return {
337
638
  id: commentData.id,
338
639
  author,
339
640
  body: content,
@@ -345,17 +646,13 @@ var RedditClient = class {
345
646
  edited: false,
346
647
  isSubmitter: false,
347
648
  permalink: commentData.permalink
348
- });
349
- } else if (json.json.errors !== void 0 && json.json.errors.length > 0) {
350
- const errors = json.json.errors.map((e) => e[1]).join(", ");
351
- return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
352
- } else return Left(/* @__PURE__ */ new Error("Failed to parse reply response"));
353
- } catch (error) {
354
- return Left(toError(error));
355
- }
649
+ };
650
+ } else if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
651
+ else throw new ApiError("Failed to parse reply response");
652
+ })).toEither((error) => classifyRedditError(error));
356
653
  }
357
654
  async deletePost(thingId) {
358
- try {
655
+ return (await Try.async(async () => {
359
656
  this.validateWriteAccess();
360
657
  const fullThingId = thingId.startsWith("t3_") || thingId.startsWith("t1_") ? thingId : `t3_${thingId}`;
361
658
  const params = new URLSearchParams();
@@ -369,21 +666,21 @@ var RedditClient = class {
369
666
  const errorText = await response.text();
370
667
  console.error(`[Reddit API] Delete failed: ${response.status} ${response.statusText}`);
371
668
  console.error(`[Reddit API] Error response: ${errorText}`);
372
- return Left(/* @__PURE__ */ new Error(`HTTP ${response.status}: ${errorText}`));
669
+ throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`);
373
670
  }
374
671
  console.error(`[Reddit API] Successfully deleted ${fullThingId}`);
375
- return Right(true);
376
- } catch (error) {
377
- console.error(`[Reddit API] Delete exception:`, error);
378
- return Left(toError(error));
379
- }
672
+ return true;
673
+ })).toEither((error) => {
674
+ if (!isRedditError(error)) console.error(`[Reddit API] Delete exception:`, error);
675
+ return classifyRedditError(error);
676
+ });
380
677
  }
381
678
  async deleteComment(thingId) {
382
679
  const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
383
680
  return this.deletePost(fullThingId);
384
681
  }
385
682
  async editPost(thingId, newText) {
386
- try {
683
+ return (await Try.async(async () => {
387
684
  this.validateWriteAccess();
388
685
  await this.enforceWriteRateLimit();
389
686
  this.checkDuplicateContent(newText);
@@ -398,23 +695,18 @@ var RedditClient = class {
398
695
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
399
696
  body: params.toString()
400
697
  })).orThrow();
401
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to edit: HTTP ${response.status}`));
698
+ if (!response.ok) throw new HttpError(response.status, `Failed to edit: HTTP ${response.status}`);
402
699
  const json = await response.json();
403
- if (json.json.errors !== void 0 && json.json.errors.length > 0) {
404
- const errors = json.json.errors.map((e) => e[1]).join(", ");
405
- return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
406
- }
407
- return Right(true);
408
- } catch (error) {
409
- return Left(toError(error));
410
- }
700
+ if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
701
+ return true;
702
+ })).toEither((error) => classifyRedditError(error));
411
703
  }
412
704
  async editComment(thingId, newText) {
413
705
  const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
414
706
  return this.editPost(fullThingId, newText);
415
707
  }
416
708
  async searchReddit(query, options = {}) {
417
- const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link" } = options;
709
+ const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link", after, before } = options;
418
710
  const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${sr}/search.json`);
419
711
  const params = new URLSearchParams({
420
712
  q: query,
@@ -422,15 +714,20 @@ var RedditClient = class {
422
714
  t: timeFilter,
423
715
  limit: limit.toString(),
424
716
  type,
425
- ...subreddit !== void 0 ? { restrict_sr: "true" } : {}
717
+ ...subreddit !== void 0 ? { restrict_sr: "true" } : {},
718
+ ...after !== void 0 ? { after } : {},
719
+ ...before !== void 0 ? { before } : {}
426
720
  });
427
- try {
721
+ const context = `Failed to search Reddit for: ${query}`;
722
+ return (await Try.async(async () => {
428
723
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
429
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to search Reddit: HTTP ${response.status}`));
430
- return Right((await response.json()).data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)));
431
- } catch (error) {
432
- return Left(/* @__PURE__ */ new Error(`Failed to search Reddit for: ${query}: ${toError(error).message}`));
433
- }
724
+ if (!response.ok) throw new HttpError(response.status, `Failed to search Reddit: HTTP ${response.status}`);
725
+ const json = await response.json();
726
+ return {
727
+ items: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
728
+ ...listingCursor(json.data)
729
+ };
730
+ })).toEither((error) => classifyRedditError(error, context));
434
731
  }
435
732
  async getPostComments(postId, subreddit, options = {}) {
436
733
  const { sort = "best", limit = 100 } = options;
@@ -438,9 +735,10 @@ var RedditClient = class {
438
735
  sort,
439
736
  limit: limit.toString()
440
737
  });
441
- try {
738
+ const context = `Failed to get comments for post ${postId}`;
739
+ return (await Try.async(async () => {
442
740
  const response = (await this.makeRequest(`/r/${subreddit}/comments/${postId}.json?${params}`)).orThrow();
443
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get comments: HTTP ${response.status}`));
741
+ if (!response.ok) throw new HttpError(response.status, `Failed to get comments: HTTP ${response.status}`);
444
742
  const json = await response.json();
445
743
  const postData = json[0].data.children[0].data;
446
744
  const post = parsePostData(postData);
@@ -464,58 +762,95 @@ var RedditClient = class {
464
762
  const { replies } = item.data;
465
763
  return [comment, ...replies !== void 0 && typeof replies !== "string" ? parseComments(replies.data.children, depth + 1) : []];
466
764
  });
467
- return Right({
765
+ return {
468
766
  post,
469
767
  comments: parseComments(json[1].data.children)
768
+ };
769
+ })).toEither((error) => classifyRedditError(error, context));
770
+ }
771
+ async getMoreComments(linkId, commentIds) {
772
+ const fullLinkId = linkId.startsWith("t3_") ? linkId : `t3_${linkId}`;
773
+ const context = `Failed to expand comments for ${fullLinkId}`;
774
+ const params = new URLSearchParams({
775
+ api_type: "json",
776
+ link_id: fullLinkId,
777
+ children: commentIds.join(",")
778
+ });
779
+ return (await Try.async(async () => {
780
+ var _json$json$data4;
781
+ const response = (await this.makeRequest(`/api/morechildren?${params}`)).orThrow();
782
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
783
+ return (((_json$json$data4 = (await response.json()).json.data) === null || _json$json$data4 === void 0 ? void 0 : _json$json$data4.things) ?? []).filter((thing) => thing.kind === "t1" && thing.data.body !== void 0).map((thing) => {
784
+ const comment = thing.data;
785
+ return {
786
+ id: comment.id,
787
+ author: comment.author,
788
+ body: comment.body ?? "",
789
+ score: comment.score,
790
+ controversiality: comment.controversiality,
791
+ subreddit: comment.subreddit,
792
+ submissionTitle: comment.link_title ?? "",
793
+ createdUtc: comment.created_utc,
794
+ edited: Boolean(comment.edited),
795
+ isSubmitter: comment.is_submitter,
796
+ permalink: comment.permalink,
797
+ parentId: comment.parent_id
798
+ };
470
799
  });
471
- } catch (error) {
472
- return Left(/* @__PURE__ */ new Error(`Failed to get comments for post ${postId}: ${toError(error).message}`));
473
- }
800
+ })).toEither((error) => classifyRedditError(error, context));
474
801
  }
475
802
  async getUserPosts(username, options = {}) {
476
- const { sort = "new", timeFilter = "all", limit = 25 } = options;
803
+ const { sort = "new", timeFilter = "all", limit = 25, after } = options;
477
804
  const params = new URLSearchParams({
478
805
  sort,
479
806
  t: timeFilter,
480
807
  limit: limit.toString()
481
808
  });
482
- try {
809
+ if (after !== void 0) params.set("after", after);
810
+ const context = `Failed to get posts for user ${username}`;
811
+ return (await Try.async(async () => {
483
812
  const response = (await this.makeRequest(`/user/${username}/submitted.json?${params}`)).orThrow();
484
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get posts for user ${username}: HTTP ${response.status}`));
485
- return Right((await response.json()).data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)));
486
- } catch (error) {
487
- return Left(/* @__PURE__ */ new Error(`Failed to get posts for user ${username}: ${toError(error).message}`));
488
- }
813
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
814
+ const json = await response.json();
815
+ return {
816
+ items: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
817
+ ...listingCursor(json.data)
818
+ };
819
+ })).toEither((error) => classifyRedditError(error, context));
489
820
  }
490
821
  async getUserComments(username, options = {}) {
491
- const { sort = "new", timeFilter = "all", limit = 25 } = options;
822
+ const { sort = "new", timeFilter = "all", limit = 25, after } = options;
492
823
  const params = new URLSearchParams({
493
824
  sort,
494
825
  t: timeFilter,
495
826
  limit: limit.toString()
496
827
  });
497
- try {
828
+ if (after !== void 0) params.set("after", after);
829
+ const context = `Failed to get comments for user ${username}`;
830
+ return (await Try.async(async () => {
498
831
  const response = (await this.makeRequest(`/user/${username}/comments.json?${params}`)).orThrow();
499
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get comments for user ${username}: HTTP ${response.status}`));
500
- return Right((await response.json()).data.children.filter((child) => child.kind === "t1").map((child) => {
501
- const comment = child.data;
502
- return {
503
- id: comment.id,
504
- author: comment.author,
505
- body: comment.body ?? "",
506
- score: comment.score,
507
- controversiality: comment.controversiality,
508
- subreddit: comment.subreddit,
509
- submissionTitle: comment.link_title ?? "",
510
- createdUtc: comment.created_utc,
511
- edited: Boolean(comment.edited),
512
- isSubmitter: comment.is_submitter,
513
- permalink: comment.permalink
514
- };
515
- }));
516
- } catch (error) {
517
- return Left(/* @__PURE__ */ new Error(`Failed to get comments for user ${username}: ${toError(error).message}`));
518
- }
832
+ if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
833
+ const json = await response.json();
834
+ return {
835
+ items: json.data.children.filter((child) => child.kind === "t1").map((child) => {
836
+ const comment = child.data;
837
+ return {
838
+ id: comment.id,
839
+ author: comment.author,
840
+ body: comment.body ?? "",
841
+ score: comment.score,
842
+ controversiality: comment.controversiality,
843
+ subreddit: comment.subreddit,
844
+ submissionTitle: comment.link_title ?? "",
845
+ createdUtc: comment.created_utc,
846
+ edited: Boolean(comment.edited),
847
+ isSubmitter: comment.is_submitter,
848
+ permalink: comment.permalink
849
+ };
850
+ }),
851
+ ...listingCursor(json.data)
852
+ };
853
+ })).toEither((error) => classifyRedditError(error, context));
519
854
  }
520
855
  };
521
856
  const clientHolder = { instance: Option.none() };
@@ -657,7 +992,7 @@ function formatSubredditInfo(subreddit) {
657
992
  //#endregion
658
993
  //#region src/index.ts
659
994
  dotenv.config({ quiet: true });
660
- const VERSION = "1.4.7";
995
+ const VERSION = "1.5.0";
661
996
  function validateUserAgent(userAgent, username) {
662
997
  if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
663
998
  console.error("[Warning] User-Agent does not follow Reddit's recommended format");
@@ -708,6 +1043,15 @@ function buildSafeModeConfig(safeMode) {
708
1043
  function unwrapClient() {
709
1044
  return getRedditClient().orThrow(/* @__PURE__ */ new Error("Reddit client not initialized"));
710
1045
  }
1046
+ function nextPageHint(after) {
1047
+ return Option(after).fold(() => "", (cursor) => `\n\n---\nMore results available — call again with after="${cursor}" for the next page.`);
1048
+ }
1049
+ function formatUserContent(heading, content) {
1050
+ return `# ${heading}\n\n${content.posts.length === 0 ? "" : `## Posts (${content.posts.length})\n${content.posts.map((post, index) => `${index + 1}. ${post.title} — r/${post.subreddit}, score ${post.score.toLocaleString()} — https://reddit.com${post.permalink}`).join("\n")}\n\n`}${content.comments.length === 0 ? "" : `## Comments (${content.comments.length})\n${content.comments.map((comment, index) => {
1051
+ const body = comment.body.length > 200 ? `${comment.body.substring(0, 200)}...` : comment.body;
1052
+ return `${index + 1}. in r/${comment.subreddit}: ${body} — https://reddit.com${comment.permalink}`;
1053
+ }).join("\n")}\n\n`}${content.posts.length === 0 && content.comments.length === 0 ? "No items found.\n\n" : ""}`.trimEnd() + nextPageHint(content.after);
1054
+ }
711
1055
  async function setupRedditClient() {
712
1056
  const clientId = process.env.REDDIT_CLIENT_ID;
713
1057
  const clientSecret = process.env.REDDIT_CLIENT_SECRET;
@@ -746,6 +1090,13 @@ async function setupRedditClient() {
746
1090
  enabled: botDisclosureMode === "auto",
747
1091
  footer: botDisclosureMode === "auto" ? process.env.REDDIT_BOT_FOOTER ?? "\n\n---\n^(🤖 I am a bot | Built with) [^reddit-mcp-server](https://github.com/jordanburke/reddit-mcp-server)" : ""
748
1092
  };
1093
+ const cacheEnabled = (process.env.REDDIT_CACHE ?? "on") !== "off";
1094
+ const cacheMaxMb = Number(process.env.REDDIT_CACHE_MAX_MB ?? "50");
1095
+ const cacheConfig = {
1096
+ enabled: cacheEnabled,
1097
+ maxBytes: (Number.isFinite(cacheMaxMb) && cacheMaxMb > 0 ? cacheMaxMb : 50) * 1024 * 1024
1098
+ };
1099
+ const maxRetriesRaw = Number(process.env.REDDIT_MAX_RETRIES ?? "3");
749
1100
  const client = initializeRedditClient({
750
1101
  clientId: clientId ?? "",
751
1102
  clientSecret: clientSecret ?? "",
@@ -754,7 +1105,13 @@ async function setupRedditClient() {
754
1105
  password,
755
1106
  authMode,
756
1107
  safeMode: safeModeConfig,
757
- botDisclosure: botDisclosureConfig
1108
+ botDisclosure: botDisclosureConfig,
1109
+ cache: cacheConfig,
1110
+ retry: {
1111
+ maxRetries: Number.isFinite(maxRetriesRaw) && maxRetriesRaw >= 0 ? Math.floor(maxRetriesRaw) : 3,
1112
+ baseDelayMs: 1e3,
1113
+ maxDelayMs: 6e4
1114
+ }
758
1115
  });
759
1116
  console.error("[Setup] Reddit client initialized");
760
1117
  console.error(`[Setup] Authentication mode: ${authMode}`);
@@ -880,6 +1237,61 @@ server.addTool({
880
1237
  });
881
1238
  }
882
1239
  });
1240
+ server.addTool({
1241
+ name: "get_me",
1242
+ description: "Get the authenticated user's own account info (karma, account status). Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD); fails in anonymous mode.",
1243
+ parameters: z.object({}),
1244
+ execute: async () => {
1245
+ return (await unwrapClient().getMe()).fold((err) => {
1246
+ throw new Error(`Failed to get authenticated user: ${err.message}`);
1247
+ }, (user) => {
1248
+ const formattedUser = formatUserInfo(user);
1249
+ return `# Your Account: u/${formattedUser.username}
1250
+
1251
+ ## Profile Overview
1252
+ - Username: u/${formattedUser.username}
1253
+ - Karma:
1254
+ - Comment Karma: ${formattedUser.karma.commentKarma.toLocaleString()}
1255
+ - Post Karma: ${formattedUser.karma.postKarma.toLocaleString()}
1256
+ - Total Karma: ${formattedUser.karma.totalKarma.toLocaleString()}
1257
+ - Account Status: ${formattedUser.accountStatus.join(", ")}
1258
+ - Account Created: ${formattedUser.accountCreated}
1259
+ - Profile URL: ${formattedUser.profileUrl}`;
1260
+ });
1261
+ }
1262
+ });
1263
+ server.addTool({
1264
+ name: "get_my_overview",
1265
+ description: "Get your own recent activity (posts and comments combined). Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD).",
1266
+ parameters: z.object({
1267
+ limit: z.number().min(1).max(100).default(25).describe("Number of items to retrieve"),
1268
+ after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page")
1269
+ }),
1270
+ execute: async (args) => {
1271
+ return (await unwrapClient().getMyOverview({
1272
+ limit: args.limit,
1273
+ after: args.after
1274
+ })).fold((err) => {
1275
+ throw new Error(`Failed to get your overview: ${err.message}`);
1276
+ }, (content) => formatUserContent("Your Overview", content));
1277
+ }
1278
+ });
1279
+ server.addTool({
1280
+ name: "get_my_saved",
1281
+ description: "Get your saved posts and comments. Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD); saved content is private.",
1282
+ parameters: z.object({
1283
+ limit: z.number().min(1).max(100).default(25).describe("Number of items to retrieve"),
1284
+ after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page")
1285
+ }),
1286
+ execute: async (args) => {
1287
+ return (await unwrapClient().getMySaved({
1288
+ limit: args.limit,
1289
+ after: args.after
1290
+ })).fold((err) => {
1291
+ throw new Error(`Failed to get saved content: ${err.message}`);
1292
+ }, (content) => formatUserContent("Your Saved Content", content));
1293
+ }
1294
+ });
883
1295
  server.addTool({
884
1296
  name: "get_user_posts",
885
1297
  description: "Get recent posts by a Reddit user with sorting and filtering options",
@@ -898,16 +1310,19 @@ server.addTool({
898
1310
  "year",
899
1311
  "all"
900
1312
  ]).default("all").describe("Time filter for top posts"),
901
- limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
1313
+ limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
1314
+ after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
902
1315
  }),
903
1316
  execute: async (args) => {
904
1317
  return (await unwrapClient().getUserPosts(args.username, {
905
1318
  sort: args.sort,
906
1319
  timeFilter: args.time_filter,
907
- limit: args.limit
1320
+ limit: args.limit,
1321
+ after: args.after
908
1322
  })).fold((err) => {
909
1323
  throw new Error(`Failed to get user posts: ${err.message}`);
910
- }, (posts) => {
1324
+ }, (page) => {
1325
+ const posts = page.items;
911
1326
  if (posts.length === 0) return `No posts found for u/${args.username} with the specified filters.`;
912
1327
  const postSummaries = posts.map((post, index) => {
913
1328
  const flags = [...post.over18 ? ["**NSFW**"] : [], ...post.spoiler === true ? ["**Spoiler**"] : []];
@@ -920,7 +1335,7 @@ server.addTool({
920
1335
  }).join("\n\n");
921
1336
  return `# Posts by u/${args.username} (${args.sort} - ${args.time_filter})
922
1337
 
923
- ${postSummaries}`;
1338
+ ${postSummaries}${nextPageHint(page.after)}`;
924
1339
  });
925
1340
  }
926
1341
  });
@@ -942,16 +1357,19 @@ server.addTool({
942
1357
  "year",
943
1358
  "all"
944
1359
  ]).default("all").describe("Time filter for top comments"),
945
- limit: z.number().min(1).max(100).default(10).describe("Number of comments to retrieve")
1360
+ limit: z.number().min(1).max(100).default(10).describe("Number of comments to retrieve"),
1361
+ after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
946
1362
  }),
947
1363
  execute: async (args) => {
948
1364
  return (await unwrapClient().getUserComments(args.username, {
949
1365
  sort: args.sort,
950
1366
  timeFilter: args.time_filter,
951
- limit: args.limit
1367
+ limit: args.limit,
1368
+ after: args.after
952
1369
  })).fold((err) => {
953
1370
  throw new Error(`Failed to get user comments: ${err.message}`);
954
- }, (comments) => {
1371
+ }, (page) => {
1372
+ const comments = page.items;
955
1373
  if (comments.length === 0) return `No comments found for u/${args.username} with the specified filters.`;
956
1374
  const commentSummaries = comments.map((comment, index) => {
957
1375
  const truncatedBody = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
@@ -967,7 +1385,7 @@ In r/${comment.subreddit} on "${comment.submissionTitle}"
967
1385
  }).join("\n\n");
968
1386
  return `# Comments by u/${args.username} (${args.sort} - ${args.time_filter})
969
1387
 
970
- ${commentSummaries}`;
1388
+ ${commentSummaries}${nextPageHint(page.after)}`;
971
1389
  });
972
1390
  }
973
1391
  });
@@ -1028,12 +1446,14 @@ server.addTool({
1028
1446
  "year",
1029
1447
  "all"
1030
1448
  ]).default("week").describe("Time period for top posts"),
1031
- limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
1449
+ limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
1450
+ after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
1032
1451
  }),
1033
1452
  execute: async (args) => {
1034
- return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit)).fold((err) => {
1453
+ return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit, args.after)).fold((err) => {
1035
1454
  throw new Error(`Failed to get top posts: ${err.message}`);
1036
- }, (posts) => {
1455
+ }, (page) => {
1456
+ const posts = page.items;
1037
1457
  if (posts.length === 0) return `No posts found in ${Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`)} for the specified time period.`;
1038
1458
  const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
1039
1459
  - Author: u/${post.author}
@@ -1043,7 +1463,51 @@ server.addTool({
1043
1463
  - Link: ${post.links.shortLink}`).join("\n\n");
1044
1464
  return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
1045
1465
 
1046
- ${postSummaries}`;
1466
+ ${postSummaries}${nextPageHint(page.after)}`;
1467
+ });
1468
+ }
1469
+ });
1470
+ server.addTool({
1471
+ name: "browse_subreddit",
1472
+ description: "Browse posts from a subreddit or the Reddit home feed with a sort order (hot, new, top, rising, controversial). The time_filter only applies to top and controversial sorts.",
1473
+ parameters: z.object({
1474
+ subreddit: z.string().optional().describe("The subreddit name (without r/ prefix). Leave empty for home feed"),
1475
+ sort: z.enum([
1476
+ "hot",
1477
+ "new",
1478
+ "top",
1479
+ "rising",
1480
+ "controversial"
1481
+ ]).default("hot").describe("Sort order for posts"),
1482
+ time_filter: z.enum([
1483
+ "hour",
1484
+ "day",
1485
+ "week",
1486
+ "month",
1487
+ "year",
1488
+ "all"
1489
+ ]).default("week").describe("Time period (only applies to top and controversial sorts)"),
1490
+ limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
1491
+ after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
1492
+ }),
1493
+ execute: async (args) => {
1494
+ return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
1495
+ throw new Error(`Failed to browse subreddit: ${err.message}`);
1496
+ }, (page) => {
1497
+ const posts = page.items;
1498
+ const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
1499
+ if (posts.length === 0) return `No posts found in ${location}.`;
1500
+ const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
1501
+ - Author: u/${post.author}
1502
+ - Score: ${post.stats.score.toLocaleString()} (${(post.stats.upvoteRatio * 100).toFixed(1)}% upvoted)
1503
+ - Comments: ${post.stats.comments.toLocaleString()}
1504
+ - Posted: ${post.metadata.posted}
1505
+ - Link: ${post.links.shortLink}`).join("\n\n");
1506
+ const timeSuffix = args.sort === "top" || args.sort === "controversial" ? `, ${args.time_filter}` : "";
1507
+ const heading = location === "home feed" ? "Home Feed" : location;
1508
+ return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
1509
+
1510
+ ${postSummaries}${nextPageHint(page.after)}`;
1047
1511
  });
1048
1512
  }
1049
1513
  });
@@ -1086,6 +1550,47 @@ ${formattedSubreddit.description.full}
1086
1550
  });
1087
1551
  }
1088
1552
  });
1553
+ server.addTool({
1554
+ name: "get_subreddit_rules",
1555
+ description: "Get a subreddit's posting rules. Useful to check requirements before creating a post to avoid auto-removal.",
1556
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
1557
+ execute: async (args) => {
1558
+ return (await unwrapClient().getSubredditRules(args.subreddit_name)).fold((err) => {
1559
+ throw new Error(`Failed to get subreddit rules: ${err.message}`);
1560
+ }, (rules) => {
1561
+ if (rules.length === 0) return `r/${args.subreddit_name} has no listed subreddit-specific rules.`;
1562
+ const ruleList = rules.map((rule, index) => {
1563
+ const applies = rule.kind === "all" ? "posts & comments" : `${rule.kind}s`;
1564
+ const detail = rule.description.trim() === "" ? "" : `\n${rule.description.trim()}`;
1565
+ return `### ${index + 1}. ${rule.shortName} _(applies to ${applies})_${detail}`;
1566
+ }).join("\n\n");
1567
+ return `# Posting Rules for r/${args.subreddit_name}
1568
+
1569
+ ${ruleList}`;
1570
+ });
1571
+ }
1572
+ });
1573
+ server.addTool({
1574
+ name: "get_post_flairs",
1575
+ description: "List the available link flairs for a subreddit (use a flair_id with create_post). Requires user credentials; many subreddits only expose flairs to members, so this may fail in anonymous mode.",
1576
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
1577
+ execute: async (args) => {
1578
+ return (await unwrapClient().getPostFlairs(args.subreddit_name)).fold((err) => {
1579
+ throw new Error(`Failed to get post flairs: ${err.message}`);
1580
+ }, (flairs) => {
1581
+ if (flairs.length === 0) return `r/${args.subreddit_name} has no selectable link flairs (or none are visible to this account).`;
1582
+ const flairList = flairs.map((flair) => {
1583
+ const editable = flair.textEditable === true ? " _(text editable)_" : "";
1584
+ return `- ${flair.text}${editable} — \`flair_id: ${flair.id}\``;
1585
+ }).join("\n");
1586
+ return `# Available Link Flairs for r/${args.subreddit_name}
1587
+
1588
+ ${flairList}
1589
+
1590
+ Pass the desired \`flair_id\` to \`create_post\`.`;
1591
+ });
1592
+ }
1593
+ });
1089
1594
  server.addTool({
1090
1595
  name: "get_trending_subreddits",
1091
1596
  description: "Get a list of currently trending subreddits",
@@ -1110,7 +1615,7 @@ server.addTool({
1110
1615
  "top",
1111
1616
  "new",
1112
1617
  "comments"
1113
- ]).default("relevance").describe("Sort order"),
1618
+ ]).default("relevance").describe("Sort order. Prefer 'relevance' (default) for finding posts about a topic. Use 'top'/'hot' only for what's currently popular and 'new' for the latest — these rank by karma/recency and, especially combined with a narrow time_filter, can surface loosely-matching posts over the best topical results."),
1114
1619
  time_filter: z.enum([
1115
1620
  "hour",
1116
1621
  "day",
@@ -1124,7 +1629,8 @@ server.addTool({
1124
1629
  "link",
1125
1630
  "sr",
1126
1631
  "user"
1127
- ]).default("link").describe("Type of content to search")
1632
+ ]).default("link").describe("Type of content to search"),
1633
+ after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
1128
1634
  }),
1129
1635
  execute: async (args) => {
1130
1636
  const client = unwrapClient();
@@ -1134,10 +1640,12 @@ server.addTool({
1134
1640
  sort: args.sort,
1135
1641
  timeFilter: args.time_filter,
1136
1642
  limit: args.limit,
1137
- type: args.type
1643
+ type: args.type,
1644
+ after: args.after
1138
1645
  })).fold((err) => {
1139
1646
  throw new Error(`Failed to search: ${err.message}`);
1140
- }, (posts) => {
1647
+ }, (page) => {
1648
+ const posts = page.items;
1141
1649
  if (posts.length === 0) {
1142
1650
  const searchLocation = Option(args.subreddit).fold(() => "", (sr) => ` in r/${sr}`);
1143
1651
  return `No results found for "${args.query}"${searchLocation}.`;
@@ -1157,7 +1665,7 @@ server.addTool({
1157
1665
 
1158
1666
  Sorted by: ${args.sort} | Time: ${args.time_filter} | Type: ${args.type}
1159
1667
 
1160
- ${searchResults}`;
1668
+ ${searchResults}${nextPageHint(page.after)}`;
1161
1669
  });
1162
1670
  }
1163
1671
  });
@@ -1168,12 +1676,14 @@ server.addTool({
1168
1676
  subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
1169
1677
  title: z.string().describe("The post title"),
1170
1678
  content: z.string().describe("The post content (text for self posts, URL for link posts)"),
1171
- is_self: z.boolean().default(true).describe("Whether this is a self post (text) or link post")
1679
+ is_self: z.boolean().default(true).describe("Whether this is a self post (text) or link post"),
1680
+ flair_id: z.string().optional().describe("Link flair template id (from get_post_flairs); many subreddits require a flair"),
1681
+ flair_text: z.string().optional().describe("Custom flair text, only for flairs whose template is text-editable")
1172
1682
  }),
1173
1683
  execute: async (args) => {
1174
1684
  const client = unwrapClient();
1175
1685
  if (process.env.REDDIT_USERNAME === void 0 || process.env.REDDIT_PASSWORD === void 0) throw new Error("User authentication required. Please set REDDIT_USERNAME and REDDIT_PASSWORD environment variables.");
1176
- return (await client.createPost(args.subreddit, args.title, args.content, args.is_self)).fold((err) => {
1686
+ return (await client.createPost(args.subreddit, args.title, args.content, args.is_self, args.flair_id, args.flair_text)).fold((err) => {
1177
1687
  throw new Error(`Failed to create post: ${err.message}`);
1178
1688
  }, (post) => {
1179
1689
  const formattedPost = formatPostInfo(post);
@@ -1333,6 +1843,33 @@ ${comment.body}
1333
1843
  });
1334
1844
  }
1335
1845
  });
1846
+ server.addTool({
1847
+ name: "get_more_comments",
1848
+ description: "Expand truncated 'load more comments' stubs in a thread. Pass the post's link id and the comment ids from a 'more' node (returned by get_post_comments) to fetch those comments.",
1849
+ parameters: z.object({
1850
+ link_id: z.string().describe("The post (link) id, with or without the t3_ prefix"),
1851
+ comment_ids: z.array(z.string()).min(1).describe("Comment ids to expand (from a 'more' node)")
1852
+ }),
1853
+ execute: async (args) => {
1854
+ return (await unwrapClient().getMoreComments(args.link_id, args.comment_ids)).fold((err) => {
1855
+ throw new Error(`Failed to expand comments: ${err.message}`);
1856
+ }, (comments) => {
1857
+ if (comments.length === 0) return "No additional comments were returned for those ids.";
1858
+ const commentList = comments.map((comment, index) => {
1859
+ const truncated = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
1860
+ const flags = [...comment.edited ? ["*(edited)*"] : [], ...comment.isSubmitter ? ["**OP**"] : []];
1861
+ return `### ${index + 1}. u/${comment.author} ${flags.join(" ")}
1862
+ > ${truncated}
1863
+
1864
+ - Score: ${comment.score.toLocaleString()}
1865
+ - Link: https://reddit.com${comment.permalink}`;
1866
+ }).join("\n\n");
1867
+ return `# Expanded Comments (${comments.length})
1868
+
1869
+ ${commentList}`;
1870
+ });
1871
+ }
1872
+ });
1336
1873
  async function main() {
1337
1874
  await setupRedditClient();
1338
1875
  const useHttp = process.env.TRANSPORT_TYPE === "httpStream" || process.env.TRANSPORT_TYPE === "http";