reddit-mcp-server 1.4.8 → 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,6 +3,93 @@ 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
6
93
  //#region src/client/response-cache.ts
7
94
  const SECOND = 1e3;
8
95
  var ResponseCache = class {
@@ -65,8 +152,13 @@ var ResponseCache = class {
65
152
  };
66
153
  //#endregion
67
154
  //#region src/client/reddit-client.ts
68
- function toError(error) {
69
- 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
+ };
70
162
  }
71
163
  function parsePostData(post) {
72
164
  return {
@@ -100,6 +192,7 @@ var RedditClient = class {
100
192
  safeMode;
101
193
  botDisclosure;
102
194
  cache;
195
+ retry;
103
196
  accessToken;
104
197
  tokenExpiry = 0;
105
198
  authenticated = false;
@@ -127,6 +220,11 @@ var RedditClient = class {
127
220
  footer: ""
128
221
  };
129
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
+ };
130
228
  }
131
229
  determineBaseUrl() {
132
230
  switch (this.authMode) {
@@ -136,13 +234,13 @@ var RedditClient = class {
136
234
  }
137
235
  }
138
236
  async makeRequest(path, options = {}) {
139
- try {
237
+ return (await Try.async(async () => {
140
238
  const url = `${this.baseUrl}${path}`;
141
239
  const method = (options.method ?? "GET").toUpperCase();
142
240
  const cacheable = this.cache !== void 0 && method === "GET";
143
241
  if (cacheable) {
144
242
  const cached = this.cache.get(url);
145
- if (cached !== void 0) return Right(new Response(cached.body, { status: cached.status }));
243
+ if (cached !== void 0) return new Response(cached.body, { status: cached.status });
146
244
  }
147
245
  const requiresAuth = this.authMode === "authenticated" || this.authMode === "auto" && this.hasCredentials;
148
246
  if (requiresAuth && (Date.now() >= this.tokenExpiry || !this.authenticated)) (await this.authenticate()).orThrow();
@@ -151,30 +249,18 @@ var RedditClient = class {
151
249
  ...options.headers
152
250
  };
153
251
  if (requiresAuth && this.accessToken !== void 0) headers["Authorization"] = `Bearer ${this.accessToken}`;
154
- const response = await fetch(url, {
155
- ...options,
156
- headers
157
- });
158
- if (response.status === 401 && this.authenticated) {
159
- (await this.authenticate()).orThrow();
160
- const retryHeaders = {
161
- ...headers,
162
- Authorization: `Bearer ${this.accessToken}`
163
- };
164
- return Right(await fetch(url, {
165
- ...options,
166
- headers: retryHeaders
167
- }));
168
- }
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;
169
257
  if (cacheable && response.ok) {
170
258
  const text = await response.text();
171
259
  this.cache.set(url, text, response.status);
172
- return Right(new Response(text, { status: response.status }));
260
+ return new Response(text, { status: response.status });
173
261
  }
174
- return Right(response);
175
- } catch (error) {
176
- return Left(toError(error));
177
- }
262
+ return response;
263
+ })).toEither((error) => error);
178
264
  }
179
265
  async authenticate() {
180
266
  if (this.authMode === "anonymous") {
@@ -186,9 +272,9 @@ var RedditClient = class {
186
272
  this.authenticated = false;
187
273
  return Right(void 0);
188
274
  }
189
- try {
275
+ return (await Try.async(async () => {
190
276
  const now = Date.now();
191
- if (this.accessToken !== void 0 && now < this.tokenExpiry) return Right(void 0);
277
+ if (this.accessToken !== void 0 && now < this.tokenExpiry) return;
192
278
  const authUrl = "https://www.reddit.com/api/v1/access_token";
193
279
  const authData = new URLSearchParams();
194
280
  const { username } = this;
@@ -210,16 +296,13 @@ var RedditClient = class {
210
296
  });
211
297
  if (!response.ok) {
212
298
  const statusText = response.statusText !== "" ? response.statusText : "Unknown Error";
213
- return Left(/* @__PURE__ */ new Error(`Authentication failed: ${response.status} ${statusText}`));
299
+ throw new Error(`Authentication failed: ${response.status} ${statusText}`);
214
300
  }
215
301
  const data = await response.json();
216
302
  this.accessToken = data.access_token;
217
303
  this.tokenExpiry = now + data.expires_in * 1e3;
218
304
  this.authenticated = true;
219
- return Right(void 0);
220
- } catch (error) {
221
- return Left(toError(error));
222
- }
305
+ })).toEither((error) => error);
223
306
  }
224
307
  async checkAuthentication() {
225
308
  if (!this.authenticated) return (await this.authenticate()).isRight();
@@ -227,8 +310,8 @@ var RedditClient = class {
227
310
  }
228
311
  validateWriteAccess() {
229
312
  if (this.username === void 0 || this.password === void 0) {
230
- 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.");
231
- 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");
232
315
  }
233
316
  }
234
317
  async enforceWriteRateLimit() {
@@ -249,8 +332,8 @@ var RedditClient = class {
249
332
  const hash = this.hashContent(content);
250
333
  const duplicate = this.recentContentRecords.find((record) => record.hash === hash);
251
334
  if (duplicate !== void 0) {
252
- 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.");
253
- 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.");
254
337
  }
255
338
  this.recentContentRecords.push({
256
339
  hash,
@@ -259,16 +342,49 @@ var RedditClient = class {
259
342
  });
260
343
  this.recentContentRecords = this.recentContentRecords.slice(-this.safeMode.maxRecentHashes);
261
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
+ }
262
377
  appendBotDisclosure(content) {
263
378
  if (!this.botDisclosure.enabled || this.botDisclosure.footer === "") return content;
264
379
  return `${content}${this.botDisclosure.footer}`;
265
380
  }
266
381
  async getUser(username) {
267
- try {
382
+ const context = `Failed to get user info for ${username}`;
383
+ return (await Try.async(async () => {
268
384
  const response = (await this.makeRequest(`/user/${username}/about.json`)).orThrow();
269
- 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}`);
270
386
  const { data } = await response.json();
271
- return Right({
387
+ return {
272
388
  name: data.name,
273
389
  id: data.id,
274
390
  commentKarma: data.comment_karma,
@@ -279,17 +395,79 @@ var RedditClient = class {
279
395
  isEmployee: data.is_employee,
280
396
  createdUtc: data.created_utc,
281
397
  profileUrl: `https://reddit.com/user/${data.name}`
282
- });
283
- } catch (error) {
284
- return Left(/* @__PURE__ */ new Error(`Failed to get user info for ${username}: ${toError(error).message}`));
285
- }
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));
286
463
  }
287
464
  async getSubredditInfo(subredditName) {
288
- try {
465
+ const context = `Failed to get subreddit info for ${subredditName}`;
466
+ return (await Try.async(async () => {
289
467
  const response = (await this.makeRequest(`/r/${subredditName}/about.json`)).orThrow();
290
- 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}`);
291
469
  const { data } = await response.json();
292
- return Right({
470
+ return {
293
471
  displayName: data.display_name,
294
472
  title: data.title,
295
473
  description: data.description,
@@ -300,26 +478,56 @@ var RedditClient = class {
300
478
  over18: data.over18,
301
479
  subredditType: data.subreddit_type,
302
480
  url: data.url
303
- });
304
- } catch (error) {
305
- return Left(/* @__PURE__ */ new Error(`Failed to get subreddit info for ${subredditName}: ${toError(error).message}`));
306
- }
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));
307
511
  }
308
- async getTopPosts(subreddit, timeFilter = "week", limit = 10) {
512
+ async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
309
513
  const endpoint = subreddit !== "" ? `/r/${subreddit}/top.json` : "/top.json";
310
514
  const params = new URLSearchParams({
311
515
  t: timeFilter,
312
516
  limit: limit.toString()
313
517
  });
314
- 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 () => {
315
521
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
316
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get top posts: HTTP ${response.status}`));
317
- return Right((await response.json()).data.children.map((child) => parsePostData(child.data)));
318
- } catch (error) {
319
- return Left(/* @__PURE__ */ new Error(`Failed to get top posts for ${subreddit !== "" ? subreddit : "home"}: ${toError(error).message}`));
320
- }
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));
321
529
  }
322
- async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10) {
530
+ async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10, after) {
323
531
  const validSorts = [
324
532
  "hot",
325
533
  "new",
@@ -327,43 +535,46 @@ var RedditClient = class {
327
535
  "rising",
328
536
  "controversial"
329
537
  ];
330
- if (!validSorts.includes(sort)) return Left(/* @__PURE__ */ new Error(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
538
+ if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
331
539
  const endpoint = subreddit !== "" ? `/r/${subreddit}/${sort}.json` : `/${sort}.json`;
332
540
  const params = new URLSearchParams({ limit: limit.toString() });
333
541
  if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
334
- try {
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 () => {
335
546
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
336
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to browse r/${subreddit !== "" ? subreddit : "home"}: HTTP ${response.status}`));
337
- return Right((await response.json()).data.children.map((child) => parsePostData(child.data)));
338
- } catch (error) {
339
- return Left(/* @__PURE__ */ new Error(`Failed to browse r/${subreddit !== "" ? subreddit : "home"} (${sort}): ${toError(error).message}`));
340
- }
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));
341
554
  }
342
555
  async getPost(postId, subreddit) {
343
556
  const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${postId}`, (sr) => `/r/${sr}/comments/${postId}.json`);
344
- try {
557
+ const context = `Failed to get post with ID ${postId}`;
558
+ return (await Try.async(async () => {
345
559
  const response = (await this.makeRequest(endpoint)).orThrow();
346
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get post with ID ${postId}: HTTP ${response.status}`));
347
- 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);
348
562
  const json = await response.json();
349
- if (json.data.children.length === 0) return Left(/* @__PURE__ */ new Error(`Post with ID ${postId} not found`));
350
- return Right(parsePostData(json.data.children[0].data));
351
- } catch (error) {
352
- return Left(/* @__PURE__ */ new Error(`Failed to get post with ID ${postId}: ${toError(error).message}`));
353
- }
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));
354
566
  }
355
567
  async getTrendingSubreddits(limit = 5) {
356
568
  const params = new URLSearchParams({ limit: limit.toString() });
357
- try {
569
+ const context = `Failed to get trending subreddits`;
570
+ return (await Try.async(async () => {
358
571
  const response = (await this.makeRequest(`/subreddits/popular.json?${params}`)).orThrow();
359
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get trending subreddits: HTTP ${response.status}`));
360
- return Right((await response.json()).data.children.map((child) => child.data.display_name));
361
- } catch (error) {
362
- return Left(/* @__PURE__ */ new Error(`Failed to get trending subreddits: ${toError(error).message}`));
363
- }
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));
364
575
  }
365
- async createPost(subreddit, title, content, isSelf = true) {
366
- try {
576
+ async createPost(subreddit, title, content, isSelf = true, flairId, flairText) {
577
+ return (await Try.async(async () => {
367
578
  var _json$json$data, _json$json$data2;
368
579
  this.validateWriteAccess();
369
580
  await this.enforceWriteRateLimit();
@@ -376,35 +587,30 @@ var RedditClient = class {
376
587
  params.append("title", title);
377
588
  params.append(isSelf ? "text" : "url", finalContent);
378
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);
379
592
  const response = (await this.makeRequest("/api/submit", {
380
593
  method: "POST",
381
594
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
382
595
  body: params.toString()
383
596
  })).orThrow();
384
- 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}`);
385
598
  const json = await response.json();
386
- if (json.json.errors !== void 0 && json.json.errors.length > 0) {
387
- const errors = json.json.errors.map((e) => e[1]).join(", ");
388
- return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
389
- }
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(", ")}`);
390
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_", ""));
391
- if (postId === void 0) return Left(/* @__PURE__ */ new Error("No post ID returned from Reddit"));
392
- return this.getPost(postId, subreddit);
393
- } catch (error) {
394
- return Left(toError(error));
395
- }
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));
396
604
  }
397
605
  async checkPostExists(postId) {
398
- try {
606
+ return (await Try.async(async () => {
399
607
  const response = (await this.makeRequest(`/api/info.json?id=t3_${postId}`)).orThrow();
400
608
  if (!response.ok) return false;
401
609
  return (await response.json()).data.children.length > 0;
402
- } catch {
403
- return false;
404
- }
610
+ })).orElse(false);
405
611
  }
406
612
  async replyToPost(postId, content) {
407
- try {
613
+ return (await Try.async(async () => {
408
614
  var _json$json$data3;
409
615
  this.validateWriteAccess();
410
616
  await this.enforceWriteRateLimit();
@@ -412,7 +618,7 @@ var RedditClient = class {
412
618
  const finalContent = this.appendBotDisclosure(content);
413
619
  const fullThingId = postId.startsWith("t3_") || postId.startsWith("t1_") ? postId : `t3_${postId}`;
414
620
  if (!postId.startsWith("t1_")) {
415
- 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`);
416
622
  }
417
623
  const params = new URLSearchParams();
418
624
  params.append("thing_id", fullThingId);
@@ -423,12 +629,12 @@ var RedditClient = class {
423
629
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
424
630
  body: params.toString()
425
631
  })).orThrow();
426
- 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}`);
427
633
  const json = await response.json();
428
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) {
429
635
  const commentData = json.json.data.things[0].data;
430
636
  const author = this.username ?? "[unknown]";
431
- return Right({
637
+ return {
432
638
  id: commentData.id,
433
639
  author,
434
640
  body: content,
@@ -440,17 +646,13 @@ var RedditClient = class {
440
646
  edited: false,
441
647
  isSubmitter: false,
442
648
  permalink: commentData.permalink
443
- });
444
- } else if (json.json.errors !== void 0 && json.json.errors.length > 0) {
445
- const errors = json.json.errors.map((e) => e[1]).join(", ");
446
- return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
447
- } else return Left(/* @__PURE__ */ new Error("Failed to parse reply response"));
448
- } catch (error) {
449
- return Left(toError(error));
450
- }
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));
451
653
  }
452
654
  async deletePost(thingId) {
453
- try {
655
+ return (await Try.async(async () => {
454
656
  this.validateWriteAccess();
455
657
  const fullThingId = thingId.startsWith("t3_") || thingId.startsWith("t1_") ? thingId : `t3_${thingId}`;
456
658
  const params = new URLSearchParams();
@@ -464,21 +666,21 @@ var RedditClient = class {
464
666
  const errorText = await response.text();
465
667
  console.error(`[Reddit API] Delete failed: ${response.status} ${response.statusText}`);
466
668
  console.error(`[Reddit API] Error response: ${errorText}`);
467
- return Left(/* @__PURE__ */ new Error(`HTTP ${response.status}: ${errorText}`));
669
+ throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`);
468
670
  }
469
671
  console.error(`[Reddit API] Successfully deleted ${fullThingId}`);
470
- return Right(true);
471
- } catch (error) {
472
- console.error(`[Reddit API] Delete exception:`, error);
473
- return Left(toError(error));
474
- }
672
+ return true;
673
+ })).toEither((error) => {
674
+ if (!isRedditError(error)) console.error(`[Reddit API] Delete exception:`, error);
675
+ return classifyRedditError(error);
676
+ });
475
677
  }
476
678
  async deleteComment(thingId) {
477
679
  const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
478
680
  return this.deletePost(fullThingId);
479
681
  }
480
682
  async editPost(thingId, newText) {
481
- try {
683
+ return (await Try.async(async () => {
482
684
  this.validateWriteAccess();
483
685
  await this.enforceWriteRateLimit();
484
686
  this.checkDuplicateContent(newText);
@@ -493,23 +695,18 @@ var RedditClient = class {
493
695
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
494
696
  body: params.toString()
495
697
  })).orThrow();
496
- 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}`);
497
699
  const json = await response.json();
498
- if (json.json.errors !== void 0 && json.json.errors.length > 0) {
499
- const errors = json.json.errors.map((e) => e[1]).join(", ");
500
- return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
501
- }
502
- return Right(true);
503
- } catch (error) {
504
- return Left(toError(error));
505
- }
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));
506
703
  }
507
704
  async editComment(thingId, newText) {
508
705
  const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
509
706
  return this.editPost(fullThingId, newText);
510
707
  }
511
708
  async searchReddit(query, options = {}) {
512
- 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;
513
710
  const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${sr}/search.json`);
514
711
  const params = new URLSearchParams({
515
712
  q: query,
@@ -517,15 +714,20 @@ var RedditClient = class {
517
714
  t: timeFilter,
518
715
  limit: limit.toString(),
519
716
  type,
520
- ...subreddit !== void 0 ? { restrict_sr: "true" } : {}
717
+ ...subreddit !== void 0 ? { restrict_sr: "true" } : {},
718
+ ...after !== void 0 ? { after } : {},
719
+ ...before !== void 0 ? { before } : {}
521
720
  });
522
- try {
721
+ const context = `Failed to search Reddit for: ${query}`;
722
+ return (await Try.async(async () => {
523
723
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
524
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to search Reddit: HTTP ${response.status}`));
525
- return Right((await response.json()).data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)));
526
- } catch (error) {
527
- return Left(/* @__PURE__ */ new Error(`Failed to search Reddit for: ${query}: ${toError(error).message}`));
528
- }
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));
529
731
  }
530
732
  async getPostComments(postId, subreddit, options = {}) {
531
733
  const { sort = "best", limit = 100 } = options;
@@ -533,9 +735,10 @@ var RedditClient = class {
533
735
  sort,
534
736
  limit: limit.toString()
535
737
  });
536
- try {
738
+ const context = `Failed to get comments for post ${postId}`;
739
+ return (await Try.async(async () => {
537
740
  const response = (await this.makeRequest(`/r/${subreddit}/comments/${postId}.json?${params}`)).orThrow();
538
- 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}`);
539
742
  const json = await response.json();
540
743
  const postData = json[0].data.children[0].data;
541
744
  const post = parsePostData(postData);
@@ -559,58 +762,95 @@ var RedditClient = class {
559
762
  const { replies } = item.data;
560
763
  return [comment, ...replies !== void 0 && typeof replies !== "string" ? parseComments(replies.data.children, depth + 1) : []];
561
764
  });
562
- return Right({
765
+ return {
563
766
  post,
564
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
+ };
565
799
  });
566
- } catch (error) {
567
- return Left(/* @__PURE__ */ new Error(`Failed to get comments for post ${postId}: ${toError(error).message}`));
568
- }
800
+ })).toEither((error) => classifyRedditError(error, context));
569
801
  }
570
802
  async getUserPosts(username, options = {}) {
571
- const { sort = "new", timeFilter = "all", limit = 25 } = options;
803
+ const { sort = "new", timeFilter = "all", limit = 25, after } = options;
572
804
  const params = new URLSearchParams({
573
805
  sort,
574
806
  t: timeFilter,
575
807
  limit: limit.toString()
576
808
  });
577
- 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 () => {
578
812
  const response = (await this.makeRequest(`/user/${username}/submitted.json?${params}`)).orThrow();
579
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get posts for user ${username}: HTTP ${response.status}`));
580
- return Right((await response.json()).data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)));
581
- } catch (error) {
582
- return Left(/* @__PURE__ */ new Error(`Failed to get posts for user ${username}: ${toError(error).message}`));
583
- }
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));
584
820
  }
585
821
  async getUserComments(username, options = {}) {
586
- const { sort = "new", timeFilter = "all", limit = 25 } = options;
822
+ const { sort = "new", timeFilter = "all", limit = 25, after } = options;
587
823
  const params = new URLSearchParams({
588
824
  sort,
589
825
  t: timeFilter,
590
826
  limit: limit.toString()
591
827
  });
592
- 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 () => {
593
831
  const response = (await this.makeRequest(`/user/${username}/comments.json?${params}`)).orThrow();
594
- if (!response.ok) return Left(/* @__PURE__ */ new Error(`Failed to get comments for user ${username}: HTTP ${response.status}`));
595
- return Right((await response.json()).data.children.filter((child) => child.kind === "t1").map((child) => {
596
- const comment = child.data;
597
- return {
598
- id: comment.id,
599
- author: comment.author,
600
- body: comment.body ?? "",
601
- score: comment.score,
602
- controversiality: comment.controversiality,
603
- subreddit: comment.subreddit,
604
- submissionTitle: comment.link_title ?? "",
605
- createdUtc: comment.created_utc,
606
- edited: Boolean(comment.edited),
607
- isSubmitter: comment.is_submitter,
608
- permalink: comment.permalink
609
- };
610
- }));
611
- } catch (error) {
612
- return Left(/* @__PURE__ */ new Error(`Failed to get comments for user ${username}: ${toError(error).message}`));
613
- }
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));
614
854
  }
615
855
  };
616
856
  const clientHolder = { instance: Option.none() };
@@ -752,7 +992,7 @@ function formatSubredditInfo(subreddit) {
752
992
  //#endregion
753
993
  //#region src/index.ts
754
994
  dotenv.config({ quiet: true });
755
- const VERSION = "1.4.8";
995
+ const VERSION = "1.5.0";
756
996
  function validateUserAgent(userAgent, username) {
757
997
  if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
758
998
  console.error("[Warning] User-Agent does not follow Reddit's recommended format");
@@ -803,6 +1043,15 @@ function buildSafeModeConfig(safeMode) {
803
1043
  function unwrapClient() {
804
1044
  return getRedditClient().orThrow(/* @__PURE__ */ new Error("Reddit client not initialized"));
805
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
+ }
806
1055
  async function setupRedditClient() {
807
1056
  const clientId = process.env.REDDIT_CLIENT_ID;
808
1057
  const clientSecret = process.env.REDDIT_CLIENT_SECRET;
@@ -847,6 +1096,7 @@ async function setupRedditClient() {
847
1096
  enabled: cacheEnabled,
848
1097
  maxBytes: (Number.isFinite(cacheMaxMb) && cacheMaxMb > 0 ? cacheMaxMb : 50) * 1024 * 1024
849
1098
  };
1099
+ const maxRetriesRaw = Number(process.env.REDDIT_MAX_RETRIES ?? "3");
850
1100
  const client = initializeRedditClient({
851
1101
  clientId: clientId ?? "",
852
1102
  clientSecret: clientSecret ?? "",
@@ -856,7 +1106,12 @@ async function setupRedditClient() {
856
1106
  authMode,
857
1107
  safeMode: safeModeConfig,
858
1108
  botDisclosure: botDisclosureConfig,
859
- cache: cacheConfig
1109
+ cache: cacheConfig,
1110
+ retry: {
1111
+ maxRetries: Number.isFinite(maxRetriesRaw) && maxRetriesRaw >= 0 ? Math.floor(maxRetriesRaw) : 3,
1112
+ baseDelayMs: 1e3,
1113
+ maxDelayMs: 6e4
1114
+ }
860
1115
  });
861
1116
  console.error("[Setup] Reddit client initialized");
862
1117
  console.error(`[Setup] Authentication mode: ${authMode}`);
@@ -982,6 +1237,61 @@ server.addTool({
982
1237
  });
983
1238
  }
984
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
+ });
985
1295
  server.addTool({
986
1296
  name: "get_user_posts",
987
1297
  description: "Get recent posts by a Reddit user with sorting and filtering options",
@@ -1000,16 +1310,19 @@ server.addTool({
1000
1310
  "year",
1001
1311
  "all"
1002
1312
  ]).default("all").describe("Time filter for top posts"),
1003
- 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")
1004
1315
  }),
1005
1316
  execute: async (args) => {
1006
1317
  return (await unwrapClient().getUserPosts(args.username, {
1007
1318
  sort: args.sort,
1008
1319
  timeFilter: args.time_filter,
1009
- limit: args.limit
1320
+ limit: args.limit,
1321
+ after: args.after
1010
1322
  })).fold((err) => {
1011
1323
  throw new Error(`Failed to get user posts: ${err.message}`);
1012
- }, (posts) => {
1324
+ }, (page) => {
1325
+ const posts = page.items;
1013
1326
  if (posts.length === 0) return `No posts found for u/${args.username} with the specified filters.`;
1014
1327
  const postSummaries = posts.map((post, index) => {
1015
1328
  const flags = [...post.over18 ? ["**NSFW**"] : [], ...post.spoiler === true ? ["**Spoiler**"] : []];
@@ -1022,7 +1335,7 @@ server.addTool({
1022
1335
  }).join("\n\n");
1023
1336
  return `# Posts by u/${args.username} (${args.sort} - ${args.time_filter})
1024
1337
 
1025
- ${postSummaries}`;
1338
+ ${postSummaries}${nextPageHint(page.after)}`;
1026
1339
  });
1027
1340
  }
1028
1341
  });
@@ -1044,16 +1357,19 @@ server.addTool({
1044
1357
  "year",
1045
1358
  "all"
1046
1359
  ]).default("all").describe("Time filter for top comments"),
1047
- 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")
1048
1362
  }),
1049
1363
  execute: async (args) => {
1050
1364
  return (await unwrapClient().getUserComments(args.username, {
1051
1365
  sort: args.sort,
1052
1366
  timeFilter: args.time_filter,
1053
- limit: args.limit
1367
+ limit: args.limit,
1368
+ after: args.after
1054
1369
  })).fold((err) => {
1055
1370
  throw new Error(`Failed to get user comments: ${err.message}`);
1056
- }, (comments) => {
1371
+ }, (page) => {
1372
+ const comments = page.items;
1057
1373
  if (comments.length === 0) return `No comments found for u/${args.username} with the specified filters.`;
1058
1374
  const commentSummaries = comments.map((comment, index) => {
1059
1375
  const truncatedBody = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
@@ -1069,7 +1385,7 @@ In r/${comment.subreddit} on "${comment.submissionTitle}"
1069
1385
  }).join("\n\n");
1070
1386
  return `# Comments by u/${args.username} (${args.sort} - ${args.time_filter})
1071
1387
 
1072
- ${commentSummaries}`;
1388
+ ${commentSummaries}${nextPageHint(page.after)}`;
1073
1389
  });
1074
1390
  }
1075
1391
  });
@@ -1130,12 +1446,14 @@ server.addTool({
1130
1446
  "year",
1131
1447
  "all"
1132
1448
  ]).default("week").describe("Time period for top posts"),
1133
- 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")
1134
1451
  }),
1135
1452
  execute: async (args) => {
1136
- 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) => {
1137
1454
  throw new Error(`Failed to get top posts: ${err.message}`);
1138
- }, (posts) => {
1455
+ }, (page) => {
1456
+ const posts = page.items;
1139
1457
  if (posts.length === 0) return `No posts found in ${Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`)} for the specified time period.`;
1140
1458
  const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
1141
1459
  - Author: u/${post.author}
@@ -1145,7 +1463,7 @@ server.addTool({
1145
1463
  - Link: ${post.links.shortLink}`).join("\n\n");
1146
1464
  return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
1147
1465
 
1148
- ${postSummaries}`;
1466
+ ${postSummaries}${nextPageHint(page.after)}`;
1149
1467
  });
1150
1468
  }
1151
1469
  });
@@ -1169,12 +1487,14 @@ server.addTool({
1169
1487
  "year",
1170
1488
  "all"
1171
1489
  ]).default("week").describe("Time period (only applies to top and controversial sorts)"),
1172
- limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
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")
1173
1492
  }),
1174
1493
  execute: async (args) => {
1175
- return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit)).fold((err) => {
1494
+ return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
1176
1495
  throw new Error(`Failed to browse subreddit: ${err.message}`);
1177
- }, (posts) => {
1496
+ }, (page) => {
1497
+ const posts = page.items;
1178
1498
  const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
1179
1499
  if (posts.length === 0) return `No posts found in ${location}.`;
1180
1500
  const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
@@ -1187,7 +1507,7 @@ server.addTool({
1187
1507
  const heading = location === "home feed" ? "Home Feed" : location;
1188
1508
  return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
1189
1509
 
1190
- ${postSummaries}`;
1510
+ ${postSummaries}${nextPageHint(page.after)}`;
1191
1511
  });
1192
1512
  }
1193
1513
  });
@@ -1230,6 +1550,47 @@ ${formattedSubreddit.description.full}
1230
1550
  });
1231
1551
  }
1232
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
+ });
1233
1594
  server.addTool({
1234
1595
  name: "get_trending_subreddits",
1235
1596
  description: "Get a list of currently trending subreddits",
@@ -1254,7 +1615,7 @@ server.addTool({
1254
1615
  "top",
1255
1616
  "new",
1256
1617
  "comments"
1257
- ]).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."),
1258
1619
  time_filter: z.enum([
1259
1620
  "hour",
1260
1621
  "day",
@@ -1268,7 +1629,8 @@ server.addTool({
1268
1629
  "link",
1269
1630
  "sr",
1270
1631
  "user"
1271
- ]).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")
1272
1634
  }),
1273
1635
  execute: async (args) => {
1274
1636
  const client = unwrapClient();
@@ -1278,10 +1640,12 @@ server.addTool({
1278
1640
  sort: args.sort,
1279
1641
  timeFilter: args.time_filter,
1280
1642
  limit: args.limit,
1281
- type: args.type
1643
+ type: args.type,
1644
+ after: args.after
1282
1645
  })).fold((err) => {
1283
1646
  throw new Error(`Failed to search: ${err.message}`);
1284
- }, (posts) => {
1647
+ }, (page) => {
1648
+ const posts = page.items;
1285
1649
  if (posts.length === 0) {
1286
1650
  const searchLocation = Option(args.subreddit).fold(() => "", (sr) => ` in r/${sr}`);
1287
1651
  return `No results found for "${args.query}"${searchLocation}.`;
@@ -1301,7 +1665,7 @@ server.addTool({
1301
1665
 
1302
1666
  Sorted by: ${args.sort} | Time: ${args.time_filter} | Type: ${args.type}
1303
1667
 
1304
- ${searchResults}`;
1668
+ ${searchResults}${nextPageHint(page.after)}`;
1305
1669
  });
1306
1670
  }
1307
1671
  });
@@ -1312,12 +1676,14 @@ server.addTool({
1312
1676
  subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
1313
1677
  title: z.string().describe("The post title"),
1314
1678
  content: z.string().describe("The post content (text for self posts, URL for link posts)"),
1315
- 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")
1316
1682
  }),
1317
1683
  execute: async (args) => {
1318
1684
  const client = unwrapClient();
1319
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.");
1320
- 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) => {
1321
1687
  throw new Error(`Failed to create post: ${err.message}`);
1322
1688
  }, (post) => {
1323
1689
  const formattedPost = formatPostInfo(post);
@@ -1477,6 +1843,33 @@ ${comment.body}
1477
1843
  });
1478
1844
  }
1479
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
+ });
1480
1873
  async function main() {
1481
1874
  await setupRedditClient();
1482
1875
  const useHttp = process.env.TRANSPORT_TYPE === "httpStream" || process.env.TRANSPORT_TYPE === "http";