reddit-mcp-server 1.4.8 → 1.5.1

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));
307
483
  }
308
- async getTopPosts(subreddit, timeFilter = "week", limit = 10) {
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));
511
+ }
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.1";
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}`);
@@ -938,7 +1193,12 @@ For details: https://support.reddithelp.com/hc/en-us/articles/42728983564564-Res
938
1193
  });
939
1194
  server.addTool({
940
1195
  name: "test_reddit_mcp_server",
941
- description: "Test the Reddit MCP Server connection and configuration",
1196
+ description: "Health check for the Reddit MCP server. Read-only and side-effect-free — inspects local configuration only and makes no Reddit API calls. Returns the server version, whether the Reddit client is initialized, whether OAuth credentials are present, and whether write access (REDDIT_USERNAME/REDDIT_PASSWORD) is configured. Use this first to diagnose setup/auth problems. Do NOT use it to check Reddit's own status or connectivity — it never contacts Reddit. A \"✗ Write Access\" result means the write tools (create_post, reply_to_post, edit_*, delete_*) will fail.",
1197
+ annotations: {
1198
+ title: "Test Reddit MCP Server",
1199
+ readOnlyHint: true,
1200
+ openWorldHint: false
1201
+ },
942
1202
  parameters: z.object({}),
943
1203
  execute: () => {
944
1204
  const client = getRedditClient();
@@ -955,8 +1215,13 @@ Ready to handle Reddit API requests!`);
955
1215
  });
956
1216
  server.addTool({
957
1217
  name: "get_user_info",
958
- description: "Get detailed information about a Reddit user including karma, account status, and activity analysis",
959
- parameters: z.object({ username: z.string().describe("The Reddit username (without u/ prefix)") }),
1218
+ description: "Get a public profile for any Reddit user: comment/post/total karma, account age and status flags, plus a short activity analysis and engagement tips. Read-only; works in anonymous mode. Returns profile stats only — use get_user_posts / get_user_comments for their actual content. Use get_me instead for your own authenticated account; do NOT expect private fields here, as only public data is returned.",
1219
+ annotations: {
1220
+ title: "Get User Info",
1221
+ readOnlyHint: true,
1222
+ openWorldHint: true
1223
+ },
1224
+ parameters: z.object({ username: z.string().describe("The target user's Reddit username, without the u/ prefix (e.g. 'spez', not 'u/spez').") }),
960
1225
  execute: async (args) => {
961
1226
  return (await unwrapClient().getUser(args.username)).fold((err) => {
962
1227
  throw new Error(`Failed to get user info: ${err.message}`);
@@ -982,16 +1247,91 @@ server.addTool({
982
1247
  });
983
1248
  }
984
1249
  });
1250
+ server.addTool({
1251
+ name: "get_me",
1252
+ description: "Get the authenticated user's own profile (karma, account age, status flags). Read-only, but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD) and fails in anonymous mode. Use this instead of get_user_info when you need the current account rather than an arbitrary user. Do NOT use it to look up other users — it always returns the logged-in account.",
1253
+ annotations: {
1254
+ title: "Get My Account",
1255
+ readOnlyHint: true,
1256
+ openWorldHint: true
1257
+ },
1258
+ parameters: z.object({}),
1259
+ execute: async () => {
1260
+ return (await unwrapClient().getMe()).fold((err) => {
1261
+ throw new Error(`Failed to get authenticated user: ${err.message}`);
1262
+ }, (user) => {
1263
+ const formattedUser = formatUserInfo(user);
1264
+ return `# Your Account: u/${formattedUser.username}
1265
+
1266
+ ## Profile Overview
1267
+ - Username: u/${formattedUser.username}
1268
+ - Karma:
1269
+ - Comment Karma: ${formattedUser.karma.commentKarma.toLocaleString()}
1270
+ - Post Karma: ${formattedUser.karma.postKarma.toLocaleString()}
1271
+ - Total Karma: ${formattedUser.karma.totalKarma.toLocaleString()}
1272
+ - Account Status: ${formattedUser.accountStatus.join(", ")}
1273
+ - Account Created: ${formattedUser.accountCreated}
1274
+ - Profile URL: ${formattedUser.profileUrl}`;
1275
+ });
1276
+ }
1277
+ });
1278
+ server.addTool({
1279
+ name: "get_my_overview",
1280
+ description: "Get the authenticated user's own recent activity — posts and comments interleaved, newest first. Read-only but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD). Returns up to `limit` items plus an `after` cursor for the next page. Use get_my_saved for saved items, or get_user_posts / get_user_comments for another user. Do NOT use this to fetch a specific post's thread — use get_post_comments.",
1281
+ annotations: {
1282
+ title: "Get My Overview",
1283
+ readOnlyHint: true,
1284
+ openWorldHint: true
1285
+ },
1286
+ parameters: z.object({
1287
+ limit: z.number().min(1).max(100).default(25).describe("How many activity items to return, 1–100 (default 25)."),
1288
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
1289
+ }),
1290
+ execute: async (args) => {
1291
+ return (await unwrapClient().getMyOverview({
1292
+ limit: args.limit,
1293
+ after: args.after
1294
+ })).fold((err) => {
1295
+ throw new Error(`Failed to get your overview: ${err.message}`);
1296
+ }, (content) => formatUserContent("Your Overview", content));
1297
+ }
1298
+ });
1299
+ server.addTool({
1300
+ name: "get_my_saved",
1301
+ description: "Get the authenticated user's saved posts and comments (private to the account). Read-only but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD). Returns up to `limit` items plus an `after` pagination cursor. Use get_my_overview for your authored activity. Do NOT use this for another user — saved items are private and have no cross-user equivalent.",
1302
+ annotations: {
1303
+ title: "Get My Saved",
1304
+ readOnlyHint: true,
1305
+ openWorldHint: true
1306
+ },
1307
+ parameters: z.object({
1308
+ limit: z.number().min(1).max(100).default(25).describe("How many saved items to return, 1–100 (default 25)."),
1309
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
1310
+ }),
1311
+ execute: async (args) => {
1312
+ return (await unwrapClient().getMySaved({
1313
+ limit: args.limit,
1314
+ after: args.after
1315
+ })).fold((err) => {
1316
+ throw new Error(`Failed to get saved content: ${err.message}`);
1317
+ }, (content) => formatUserContent("Your Saved Content", content));
1318
+ }
1319
+ });
985
1320
  server.addTool({
986
1321
  name: "get_user_posts",
987
- description: "Get recent posts by a Reddit user with sorting and filtering options",
1322
+ description: "Get posts submitted by a specific user, with sort (new/hot/top) and time filter. Read-only; works anonymously. Returns a page of posts (title, subreddit, score, upvote ratio, comment count, permalink) plus an `after` cursor for paging. Use get_user_comments for their comments, or get_user_info for karma/profile stats. Do NOT use this to search a subreddit — use search_reddit or browse_subreddit.",
1323
+ annotations: {
1324
+ title: "Get User Posts",
1325
+ readOnlyHint: true,
1326
+ openWorldHint: true
1327
+ },
988
1328
  parameters: z.object({
989
- username: z.string().describe("The Reddit username (without u/ prefix)"),
1329
+ username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
990
1330
  sort: z.enum([
991
1331
  "new",
992
1332
  "hot",
993
1333
  "top"
994
- ]).default("new").describe("Sort order for posts"),
1334
+ ]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
995
1335
  time_filter: z.enum([
996
1336
  "hour",
997
1337
  "day",
@@ -999,17 +1339,20 @@ server.addTool({
999
1339
  "month",
1000
1340
  "year",
1001
1341
  "all"
1002
- ]).default("all").describe("Time filter for top posts"),
1003
- limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
1342
+ ]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
1343
+ limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
1344
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1004
1345
  }),
1005
1346
  execute: async (args) => {
1006
1347
  return (await unwrapClient().getUserPosts(args.username, {
1007
1348
  sort: args.sort,
1008
1349
  timeFilter: args.time_filter,
1009
- limit: args.limit
1350
+ limit: args.limit,
1351
+ after: args.after
1010
1352
  })).fold((err) => {
1011
1353
  throw new Error(`Failed to get user posts: ${err.message}`);
1012
- }, (posts) => {
1354
+ }, (page) => {
1355
+ const posts = page.items;
1013
1356
  if (posts.length === 0) return `No posts found for u/${args.username} with the specified filters.`;
1014
1357
  const postSummaries = posts.map((post, index) => {
1015
1358
  const flags = [...post.over18 ? ["**NSFW**"] : [], ...post.spoiler === true ? ["**Spoiler**"] : []];
@@ -1022,20 +1365,25 @@ server.addTool({
1022
1365
  }).join("\n\n");
1023
1366
  return `# Posts by u/${args.username} (${args.sort} - ${args.time_filter})
1024
1367
 
1025
- ${postSummaries}`;
1368
+ ${postSummaries}${nextPageHint(page.after)}`;
1026
1369
  });
1027
1370
  }
1028
1371
  });
1029
1372
  server.addTool({
1030
1373
  name: "get_user_comments",
1031
- description: "Get recent comments by a Reddit user with sorting and filtering options",
1374
+ description: "Get comments made by a specific user, with sort (new/hot/top) and time filter. Read-only; works anonymously. Returns a page of comments (subreddit, parent post title, body excerpt, score, permalink) plus an `after` cursor. Use get_user_posts for their submissions, or get_user_info for karma/profile stats. Do NOT use this to read one post's thread — use get_post_comments.",
1375
+ annotations: {
1376
+ title: "Get User Comments",
1377
+ readOnlyHint: true,
1378
+ openWorldHint: true
1379
+ },
1032
1380
  parameters: z.object({
1033
- username: z.string().describe("The Reddit username (without u/ prefix)"),
1381
+ username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
1034
1382
  sort: z.enum([
1035
1383
  "new",
1036
1384
  "hot",
1037
1385
  "top"
1038
- ]).default("new").describe("Sort order for comments"),
1386
+ ]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
1039
1387
  time_filter: z.enum([
1040
1388
  "hour",
1041
1389
  "day",
@@ -1043,17 +1391,20 @@ server.addTool({
1043
1391
  "month",
1044
1392
  "year",
1045
1393
  "all"
1046
- ]).default("all").describe("Time filter for top comments"),
1047
- limit: z.number().min(1).max(100).default(10).describe("Number of comments to retrieve")
1394
+ ]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
1395
+ limit: z.number().min(1).max(100).default(10).describe("How many comments to return, 1–100 (default 10)."),
1396
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1048
1397
  }),
1049
1398
  execute: async (args) => {
1050
1399
  return (await unwrapClient().getUserComments(args.username, {
1051
1400
  sort: args.sort,
1052
1401
  timeFilter: args.time_filter,
1053
- limit: args.limit
1402
+ limit: args.limit,
1403
+ after: args.after
1054
1404
  })).fold((err) => {
1055
1405
  throw new Error(`Failed to get user comments: ${err.message}`);
1056
- }, (comments) => {
1406
+ }, (page) => {
1407
+ const comments = page.items;
1057
1408
  if (comments.length === 0) return `No comments found for u/${args.username} with the specified filters.`;
1058
1409
  const commentSummaries = comments.map((comment, index) => {
1059
1410
  const truncatedBody = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
@@ -1069,16 +1420,21 @@ In r/${comment.subreddit} on "${comment.submissionTitle}"
1069
1420
  }).join("\n\n");
1070
1421
  return `# Comments by u/${args.username} (${args.sort} - ${args.time_filter})
1071
1422
 
1072
- ${commentSummaries}`;
1423
+ ${commentSummaries}${nextPageHint(page.after)}`;
1073
1424
  });
1074
1425
  }
1075
1426
  });
1076
1427
  server.addTool({
1077
1428
  name: "get_reddit_post",
1078
- description: "Get detailed information about a specific Reddit post including content, stats, and engagement analysis",
1429
+ description: "Get a single post by subreddit + post id: title, author, self-text or link content, score, upvote ratio, comment count, flair/flags, and an engagement analysis. Read-only; works anonymously. Returns the post only — use get_post_comments for its comment thread. Do NOT use this to list a subreddit's posts (use browse_subreddit / get_top_posts) or to find posts by keyword (use search_reddit).",
1430
+ annotations: {
1431
+ title: "Get Reddit Post",
1432
+ readOnlyHint: true,
1433
+ openWorldHint: true
1434
+ },
1079
1435
  parameters: z.object({
1080
- subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
1081
- post_id: z.string().describe("The Reddit post ID")
1436
+ subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'programming')."),
1437
+ post_id: z.string().describe("Base36 post id — the segment after /comments/ in a permalink like reddit.com/r/<sub>/comments/<post_id>/... (e.g. '1abc23'). With or without a t3_ prefix.")
1082
1438
  }),
1083
1439
  execute: async (args) => {
1084
1440
  return (await unwrapClient().getPost(args.post_id, args.subreddit)).fold((err) => {
@@ -1119,9 +1475,14 @@ ${formattedPost.bestTimeToEngage}`;
1119
1475
  });
1120
1476
  server.addTool({
1121
1477
  name: "get_top_posts",
1122
- description: "Get top posts from a subreddit or from the Reddit home feed",
1478
+ description: "Get the top-scoring posts from a subreddit — or from the authenticated home feed if no subreddit is given — within a time window (hour…all). Read-only; works anonymously. Returns a page of posts (title, author, score, upvote ratio, comments, link) plus an `after` cursor. This is a shortcut for the 'top' sort; use browse_subreddit for hot/new/rising/controversial, or search_reddit to find posts by keyword.",
1479
+ annotations: {
1480
+ title: "Get Top Posts",
1481
+ readOnlyHint: true,
1482
+ openWorldHint: true
1483
+ },
1123
1484
  parameters: z.object({
1124
- subreddit: z.string().optional().describe("The subreddit name (without r/ prefix). Leave empty for home feed"),
1485
+ subreddit: z.string().optional().describe("Subreddit to read, without the r/ prefix (e.g. 'science'). Omit to use the authenticated home feed (requires credentials)."),
1125
1486
  time_filter: z.enum([
1126
1487
  "hour",
1127
1488
  "day",
@@ -1129,13 +1490,15 @@ server.addTool({
1129
1490
  "month",
1130
1491
  "year",
1131
1492
  "all"
1132
- ]).default("week").describe("Time period for top posts"),
1133
- limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
1493
+ ]).default("week").describe("Time window the 'top' ranking is computed over (e.g. 'day' = top today). Default 'week'."),
1494
+ limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
1495
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1134
1496
  }),
1135
1497
  execute: async (args) => {
1136
- return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit)).fold((err) => {
1498
+ return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit, args.after)).fold((err) => {
1137
1499
  throw new Error(`Failed to get top posts: ${err.message}`);
1138
- }, (posts) => {
1500
+ }, (page) => {
1501
+ const posts = page.items;
1139
1502
  if (posts.length === 0) return `No posts found in ${Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`)} for the specified time period.`;
1140
1503
  const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
1141
1504
  - Author: u/${post.author}
@@ -1145,22 +1508,27 @@ server.addTool({
1145
1508
  - Link: ${post.links.shortLink}`).join("\n\n");
1146
1509
  return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
1147
1510
 
1148
- ${postSummaries}`;
1511
+ ${postSummaries}${nextPageHint(page.after)}`;
1149
1512
  });
1150
1513
  }
1151
1514
  });
1152
1515
  server.addTool({
1153
1516
  name: "browse_subreddit",
1154
- 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.",
1517
+ description: "Browse a subreddit — or the authenticated home feed when no subreddit is given — by sort order: hot, new, top, rising, or controversial. Read-only; works anonymously. `time_filter` applies only to the top and controversial sorts. Returns a page of posts (title, author, score, upvote ratio, comments, link) plus an `after` cursor. Use get_top_posts as a shortcut for the top sort, or search_reddit to find posts by keyword rather than by feed order.",
1518
+ annotations: {
1519
+ title: "Browse Subreddit",
1520
+ readOnlyHint: true,
1521
+ openWorldHint: true
1522
+ },
1155
1523
  parameters: z.object({
1156
- subreddit: z.string().optional().describe("The subreddit name (without r/ prefix). Leave empty for home feed"),
1524
+ subreddit: z.string().optional().describe("Subreddit to browse, without the r/ prefix (e.g. 'news'). Omit to use the authenticated home feed (requires credentials)."),
1157
1525
  sort: z.enum([
1158
1526
  "hot",
1159
1527
  "new",
1160
1528
  "top",
1161
1529
  "rising",
1162
1530
  "controversial"
1163
- ]).default("hot").describe("Sort order for posts"),
1531
+ ]).default("hot").describe("Feed ordering: 'hot' (default), 'new', 'rising', 'top', or 'controversial'. 'top'/'controversial' honor `time_filter`."),
1164
1532
  time_filter: z.enum([
1165
1533
  "hour",
1166
1534
  "day",
@@ -1168,13 +1536,15 @@ server.addTool({
1168
1536
  "month",
1169
1537
  "year",
1170
1538
  "all"
1171
- ]).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")
1539
+ ]).default("week").describe("Time window; only applies to sort='top' or 'controversial'. Ignored otherwise. Default 'week'."),
1540
+ limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
1541
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1173
1542
  }),
1174
1543
  execute: async (args) => {
1175
- return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit)).fold((err) => {
1544
+ return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
1176
1545
  throw new Error(`Failed to browse subreddit: ${err.message}`);
1177
- }, (posts) => {
1546
+ }, (page) => {
1547
+ const posts = page.items;
1178
1548
  const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
1179
1549
  if (posts.length === 0) return `No posts found in ${location}.`;
1180
1550
  const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
@@ -1187,14 +1557,19 @@ server.addTool({
1187
1557
  const heading = location === "home feed" ? "Home Feed" : location;
1188
1558
  return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
1189
1559
 
1190
- ${postSummaries}`;
1560
+ ${postSummaries}${nextPageHint(page.after)}`;
1191
1561
  });
1192
1562
  }
1193
1563
  });
1194
1564
  server.addTool({
1195
1565
  name: "get_subreddit_info",
1196
- description: "Get detailed information about a subreddit including description, stats, and community analysis",
1197
- parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
1566
+ description: "Get a subreddit's profile: title, description, subscriber and active-user counts, creation date, flags, wiki/link URLs, plus a community analysis and posting tips. Read-only; works anonymously. Returns metadata about the community itself — use browse_subreddit / get_top_posts for its posts, or get_subreddit_rules for its posting rules. Do NOT use this to find subreddits by topic — use search_reddit with type='sr'.",
1567
+ annotations: {
1568
+ title: "Get Subreddit Info",
1569
+ readOnlyHint: true,
1570
+ openWorldHint: true
1571
+ },
1572
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'askscience').") }),
1198
1573
  execute: async (args) => {
1199
1574
  return (await unwrapClient().getSubredditInfo(args.subreddit_name)).fold((err) => {
1200
1575
  throw new Error(`Failed to get subreddit info: ${err.message}`);
@@ -1230,9 +1605,65 @@ ${formattedSubreddit.description.full}
1230
1605
  });
1231
1606
  }
1232
1607
  });
1608
+ server.addTool({
1609
+ name: "get_subreddit_rules",
1610
+ description: "Get a subreddit's posting rules (each rule's name, what it applies to, and its description). Read-only; works anonymously. Returns the rules list, or a note when the subreddit lists none. Call this before create_post to check requirements and avoid auto-removal. For available post flairs use get_post_flairs instead.",
1611
+ annotations: {
1612
+ title: "Get Subreddit Rules",
1613
+ readOnlyHint: true,
1614
+ openWorldHint: true
1615
+ },
1616
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'AskReddit').") }),
1617
+ execute: async (args) => {
1618
+ return (await unwrapClient().getSubredditRules(args.subreddit_name)).fold((err) => {
1619
+ throw new Error(`Failed to get subreddit rules: ${err.message}`);
1620
+ }, (rules) => {
1621
+ if (rules.length === 0) return `r/${args.subreddit_name} has no listed subreddit-specific rules.`;
1622
+ const ruleList = rules.map((rule, index) => {
1623
+ const applies = rule.kind === "all" ? "posts & comments" : `${rule.kind}s`;
1624
+ const detail = rule.description.trim() === "" ? "" : `\n${rule.description.trim()}`;
1625
+ return `### ${index + 1}. ${rule.shortName} _(applies to ${applies})_${detail}`;
1626
+ }).join("\n\n");
1627
+ return `# Posting Rules for r/${args.subreddit_name}
1628
+
1629
+ ${ruleList}`;
1630
+ });
1631
+ }
1632
+ });
1633
+ server.addTool({
1634
+ name: "get_post_flairs",
1635
+ description: "List a subreddit's selectable link flairs (flair text + flair_id) for use with create_post. Read-only, but requires user credentials; many subreddits expose flairs only to members, so this can 403 or return empty anonymously. Pass a returned flair_id (and flair_text for text-editable flairs) to create_post. For the subreddit's posting rules use get_subreddit_rules instead.",
1636
+ annotations: {
1637
+ title: "Get Post Flairs",
1638
+ readOnlyHint: true,
1639
+ openWorldHint: true
1640
+ },
1641
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'gadgets').") }),
1642
+ execute: async (args) => {
1643
+ return (await unwrapClient().getPostFlairs(args.subreddit_name)).fold((err) => {
1644
+ throw new Error(`Failed to get post flairs: ${err.message}`);
1645
+ }, (flairs) => {
1646
+ if (flairs.length === 0) return `r/${args.subreddit_name} has no selectable link flairs (or none are visible to this account).`;
1647
+ const flairList = flairs.map((flair) => {
1648
+ const editable = flair.textEditable === true ? " _(text editable)_" : "";
1649
+ return `- ${flair.text}${editable} — \`flair_id: ${flair.id}\``;
1650
+ }).join("\n");
1651
+ return `# Available Link Flairs for r/${args.subreddit_name}
1652
+
1653
+ ${flairList}
1654
+
1655
+ Pass the desired \`flair_id\` to \`create_post\`.`;
1656
+ });
1657
+ }
1658
+ });
1233
1659
  server.addTool({
1234
1660
  name: "get_trending_subreddits",
1235
- description: "Get a list of currently trending subreddits",
1661
+ description: "Get the subreddits Reddit is currently featuring as trending/popular. Read-only, no parameters; works anonymously. Returns a list of subreddit names that changes through the day (cached briefly server-side). To find subreddits by keyword instead of by trend, use search_reddit with type='sr'.",
1662
+ annotations: {
1663
+ title: "Get Trending Subreddits",
1664
+ readOnlyHint: true,
1665
+ openWorldHint: true
1666
+ },
1236
1667
  parameters: z.object({}),
1237
1668
  execute: async () => {
1238
1669
  return (await unwrapClient().getTrendingSubreddits()).fold((err) => {
@@ -1244,17 +1675,22 @@ ${trendingSubreddits.map((subreddit, index) => `${index + 1}. r/${subreddit}`).j
1244
1675
  });
1245
1676
  server.addTool({
1246
1677
  name: "search_reddit",
1247
- description: "Search Reddit for posts and content across subreddits",
1678
+ description: "Search Reddit for posts — or subreddits/users via `type` — optionally scoped to one subreddit, with sort and time filters. Read-only; works anonymously. Returns a page of results (title, subreddit, author, score, comments, link) plus an `after` cursor for paging. Use this to find content by keyword; use browse_subreddit / get_top_posts to list a known subreddit's feed instead.",
1679
+ annotations: {
1680
+ title: "Search Reddit",
1681
+ readOnlyHint: true,
1682
+ openWorldHint: true
1683
+ },
1248
1684
  parameters: z.object({
1249
- query: z.string().describe("Search query"),
1250
- subreddit: z.string().optional().describe("Limit search to specific subreddit (without r/ prefix)"),
1685
+ query: z.string().describe("Search terms; supports Reddit operators (quotes for exact phrases, author:name, self:yes). Must be non-empty."),
1686
+ subreddit: z.string().optional().describe("Restrict results to this subreddit, without the r/ prefix (e.g. 'python'). Omit to search all of Reddit."),
1251
1687
  sort: z.enum([
1252
1688
  "relevance",
1253
1689
  "hot",
1254
1690
  "top",
1255
1691
  "new",
1256
1692
  "comments"
1257
- ]).default("relevance").describe("Sort order"),
1693
+ ]).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
1694
  time_filter: z.enum([
1259
1695
  "hour",
1260
1696
  "day",
@@ -1262,13 +1698,14 @@ server.addTool({
1262
1698
  "month",
1263
1699
  "year",
1264
1700
  "all"
1265
- ]).default("all").describe("Time filter"),
1266
- limit: z.number().min(1).max(100).default(10).describe("Number of results"),
1701
+ ]).default("all").describe("Restrict to results from this recent window (e.g. 'week'). Default 'all' (no time limit)."),
1702
+ limit: z.number().min(1).max(100).default(10).describe("How many results to return, 1–100 (default 10)."),
1267
1703
  type: z.enum([
1268
1704
  "link",
1269
1705
  "sr",
1270
1706
  "user"
1271
- ]).default("link").describe("Type of content to search")
1707
+ ]).default("link").describe("What to search for: 'link' = posts (default), 'sr' = subreddits, 'user' = users."),
1708
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1272
1709
  }),
1273
1710
  execute: async (args) => {
1274
1711
  const client = unwrapClient();
@@ -1278,10 +1715,12 @@ server.addTool({
1278
1715
  sort: args.sort,
1279
1716
  timeFilter: args.time_filter,
1280
1717
  limit: args.limit,
1281
- type: args.type
1718
+ type: args.type,
1719
+ after: args.after
1282
1720
  })).fold((err) => {
1283
1721
  throw new Error(`Failed to search: ${err.message}`);
1284
- }, (posts) => {
1722
+ }, (page) => {
1723
+ const posts = page.items;
1285
1724
  if (posts.length === 0) {
1286
1725
  const searchLocation = Option(args.subreddit).fold(() => "", (sr) => ` in r/${sr}`);
1287
1726
  return `No results found for "${args.query}"${searchLocation}.`;
@@ -1301,23 +1740,32 @@ server.addTool({
1301
1740
 
1302
1741
  Sorted by: ${args.sort} | Time: ${args.time_filter} | Type: ${args.type}
1303
1742
 
1304
- ${searchResults}`;
1743
+ ${searchResults}${nextPageHint(page.after)}`;
1305
1744
  });
1306
1745
  }
1307
1746
  });
1308
1747
  server.addTool({
1309
1748
  name: "create_post",
1310
- description: "Create a new post in a subreddit (requires REDDIT_USERNAME and REDDIT_PASSWORD). WARNING: Rapid posting or duplicate content may trigger Reddit's spam detection and result in account bans. Consider enabling REDDIT_SAFE_MODE=standard for protection.",
1749
+ description: "Create a new text or link post in a subreddit. Mutating and NOT idempotent — each call publishes a separate post. Requires REDDIT_USERNAME and REDDIT_PASSWORD; fails without them. Returns the new post's id and URL. Check get_subreddit_rules and get_post_flairs first, since many subreddits require a flair or reject certain content. WARNING: rapid posting or duplicate content may trigger Reddit's spam detection and account bans — enable REDDIT_SAFE_MODE=standard for rate limiting and duplicate detection.",
1750
+ annotations: {
1751
+ title: "Create Post",
1752
+ readOnlyHint: false,
1753
+ destructiveHint: false,
1754
+ idempotentHint: false,
1755
+ openWorldHint: true
1756
+ },
1311
1757
  parameters: z.object({
1312
- subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
1313
- title: z.string().describe("The post title"),
1314
- 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")
1758
+ subreddit: z.string().describe("Target subreddit, without the r/ prefix (e.g. 'test')."),
1759
+ title: z.string().describe("Post title (cannot be edited after creation)."),
1760
+ content: z.string().describe("For a self post (is_self=true): the body text, Reddit markdown supported. For a link post (is_self=false): the destination URL."),
1761
+ is_self: z.boolean().default(true).describe("true = text/self post using `content` as the body (default); false = link post using `content` as the URL."),
1762
+ flair_id: z.string().optional().describe("Link flair template id from get_post_flairs; many subreddits require one or the post is auto-removed."),
1763
+ flair_text: z.string().optional().describe("Custom flair text, allowed only for flairs whose template is text-editable.")
1316
1764
  }),
1317
1765
  execute: async (args) => {
1318
1766
  const client = unwrapClient();
1319
1767
  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) => {
1768
+ return (await client.createPost(args.subreddit, args.title, args.content, args.is_self, args.flair_id, args.flair_text)).fold((err) => {
1321
1769
  throw new Error(`Failed to create post: ${err.message}`);
1322
1770
  }, (post) => {
1323
1771
  const formattedPost = formatPostInfo(post);
@@ -1335,10 +1783,17 @@ Your post has been successfully submitted to r/${formattedPost.subreddit}.`;
1335
1783
  });
1336
1784
  server.addTool({
1337
1785
  name: "reply_to_post",
1338
- description: "Post a reply to an existing Reddit post or comment (requires REDDIT_USERNAME and REDDIT_PASSWORD). WARNING: Rapid commenting or duplicate content may trigger Reddit's spam detection. Enable REDDIT_SAFE_MODE=standard for rate limiting and duplicate detection.",
1786
+ description: "Post a reply to an existing post or comment. Mutating and NOT idempotent — each call adds a new comment. Requires REDDIT_USERNAME and REDDIT_PASSWORD. The parent is identified by its thing id — t3_ for a post, t1_ for a comment — so this creates both top-level and nested replies. Returns the new comment's id. Use edit_comment to change a reply you already posted. WARNING: rapid or duplicate replies may trigger Reddit's spam detection; enable REDDIT_SAFE_MODE=standard for rate limiting and duplicate detection.",
1787
+ annotations: {
1788
+ title: "Reply to Post or Comment",
1789
+ readOnlyHint: false,
1790
+ destructiveHint: false,
1791
+ idempotentHint: false,
1792
+ openWorldHint: true
1793
+ },
1339
1794
  parameters: z.object({
1340
- post_id: z.string().describe("The Reddit post ID (thing_id, e.g., t3_xxxxx for posts, t1_xxxxx for comments)"),
1341
- content: z.string().describe("The reply content")
1795
+ post_id: z.string().describe("Parent thing id to reply under: t3_<id> for a post (creates a top-level comment) or t1_<id> for a comment (creates a nested reply)."),
1796
+ content: z.string().describe("Reply body text; Reddit markdown supported.")
1342
1797
  }),
1343
1798
  execute: async (args) => {
1344
1799
  const client = unwrapClient();
@@ -1357,8 +1812,15 @@ Your reply has been successfully posted.`);
1357
1812
  });
1358
1813
  server.addTool({
1359
1814
  name: "delete_post",
1360
- description: "Delete your own Reddit post (requires REDDIT_USERNAME and REDDIT_PASSWORD). WARNING: This action is permanent and cannot be undone!",
1361
- parameters: z.object({ thing_id: z.string().describe("The full Reddit thing ID (e.g., 't3_abc123' for posts) or just the post ID (e.g., 'abc123'). The 't3_' prefix will be added automatically if missing.") }),
1815
+ description: "Permanently delete one of your own posts. Mutating and destructive but idempotent — deleting an already-deleted post is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and only works on posts authored by the authenticated account. Only affects the post you name; use delete_comment for comments. WARNING: this cannot be undone — the content is removed, though the post id remains.",
1816
+ annotations: {
1817
+ title: "Delete Post",
1818
+ readOnlyHint: false,
1819
+ destructiveHint: true,
1820
+ idempotentHint: true,
1821
+ openWorldHint: true
1822
+ },
1823
+ parameters: z.object({ thing_id: z.string().describe("The post to delete: a full thing id 't3_<id>' or just the base36 post id '<id>' (the 't3_' prefix is added automatically). Must be a post you authored.") }),
1362
1824
  execute: async (args) => {
1363
1825
  const client = unwrapClient();
1364
1826
  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.");
@@ -1373,8 +1835,15 @@ The post ${args.thing_id} has been permanently deleted from Reddit.
1373
1835
  });
1374
1836
  server.addTool({
1375
1837
  name: "delete_comment",
1376
- description: "Delete your own Reddit comment (requires REDDIT_USERNAME and REDDIT_PASSWORD). WARNING: This action is permanent and cannot be undone!",
1377
- parameters: z.object({ thing_id: z.string().describe("The full Reddit thing ID (e.g., 't1_abc123' for comments) or just the comment ID (e.g., 'abc123'). The 't1_' prefix will be added automatically if missing.") }),
1838
+ description: "Permanently delete one of your own comments. Mutating and destructive but idempotent — deleting an already-deleted comment is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and only works on comments authored by the authenticated account. Only affects the comment you name; use delete_post for posts. WARNING: this cannot be undone — the content is removed, though the comment id remains.",
1839
+ annotations: {
1840
+ title: "Delete Comment",
1841
+ readOnlyHint: false,
1842
+ destructiveHint: true,
1843
+ idempotentHint: true,
1844
+ openWorldHint: true
1845
+ },
1846
+ parameters: z.object({ thing_id: z.string().describe("The comment to delete: a full thing id 't1_<id>' or just the base36 comment id '<id>' (the 't1_' prefix is added automatically). Must be a comment you authored.") }),
1378
1847
  execute: async (args) => {
1379
1848
  const client = unwrapClient();
1380
1849
  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.");
@@ -1389,10 +1858,17 @@ The comment ${args.thing_id} has been permanently deleted from Reddit.
1389
1858
  });
1390
1859
  server.addTool({
1391
1860
  name: "edit_post",
1392
- description: "Edit your own Reddit post (self-text posts only, requires REDDIT_USERNAME and REDDIT_PASSWORD). You can only edit the text content of self posts, not titles or link posts. WARNING: Rapid edits may trigger spam detection. Enable REDDIT_SAFE_MODE for protection.",
1861
+ description: "Replace the body text of one of your own self-text posts. Mutating and idempotent (same text → same result); it overwrites the previous body. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and works only on self posts you authored — titles and link posts cannot be edited. Adds an \"edited\" marker. Use create_post to make a new post, or edit_comment for comments. WARNING: rapid edits may trigger spam detection; enable REDDIT_SAFE_MODE for protection.",
1862
+ annotations: {
1863
+ title: "Edit Post",
1864
+ readOnlyHint: false,
1865
+ destructiveHint: true,
1866
+ idempotentHint: true,
1867
+ openWorldHint: true
1868
+ },
1393
1869
  parameters: z.object({
1394
- thing_id: z.string().describe("The full Reddit thing ID (e.g., 't3_abc123' for posts) or just the post ID (e.g., 'abc123'). The 't3_' prefix will be added automatically if missing."),
1395
- new_text: z.string().describe("The new text content for the post. Supports Reddit markdown formatting.")
1870
+ thing_id: z.string().describe("The post to edit: a full thing id 't3_<id>' or just the base36 post id '<id>' (the 't3_' prefix is added automatically). Must be a self-text post you authored."),
1871
+ new_text: z.string().describe("Replacement body text; fully overwrites the current body. Reddit markdown supported.")
1396
1872
  }),
1397
1873
  execute: async (args) => {
1398
1874
  const client = unwrapClient();
@@ -1412,10 +1888,17 @@ The post ${args.thing_id} has been updated with your new content.
1412
1888
  });
1413
1889
  server.addTool({
1414
1890
  name: "edit_comment",
1415
- description: "Edit your own Reddit comment (requires REDDIT_USERNAME and REDDIT_PASSWORD). Update the text content of a comment you previously posted. WARNING: Rapid edits may trigger spam detection. Enable REDDIT_SAFE_MODE for protection.",
1891
+ description: "Replace the text of one of your own comments. Mutating and idempotent (same text → same result); it overwrites the previous content. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and works only on comments you authored. Adds an \"edited\" marker. Use reply_to_post to add a new comment, or edit_post for posts. WARNING: rapid edits may trigger spam detection; enable REDDIT_SAFE_MODE for protection.",
1892
+ annotations: {
1893
+ title: "Edit Comment",
1894
+ readOnlyHint: false,
1895
+ destructiveHint: true,
1896
+ idempotentHint: true,
1897
+ openWorldHint: true
1898
+ },
1416
1899
  parameters: z.object({
1417
- thing_id: z.string().describe("The full Reddit thing ID (e.g., 't1_abc123' for comments) or just the comment ID (e.g., 'abc123'). The 't1_' prefix will be added automatically if missing."),
1418
- new_text: z.string().describe("The new text content for the comment. Supports Reddit markdown formatting.")
1900
+ thing_id: z.string().describe("The comment to edit: a full thing id 't1_<id>' or just the base36 comment id '<id>' (the 't1_' prefix is added automatically). Must be a comment you authored."),
1901
+ new_text: z.string().describe("Replacement comment text; fully overwrites the current content. Reddit markdown supported.")
1419
1902
  }),
1420
1903
  execute: async (args) => {
1421
1904
  const client = unwrapClient();
@@ -1431,10 +1914,15 @@ The comment ${args.thing_id} has been updated with your new content.
1431
1914
  });
1432
1915
  server.addTool({
1433
1916
  name: "get_post_comments",
1434
- description: "Get comments from a specific Reddit post",
1917
+ description: "Get the comment thread for a post (by post id + subreddit), sorted best/top/new/controversial/old/qa. Read-only; works anonymously. Returns the post header plus threaded comments (author, OP/edited badges, score, body, nesting depth) up to `limit`. Long threads are truncated with 'load more' stubs — expand those with get_more_comments. Use get_reddit_post for just the post body, not the thread.",
1918
+ annotations: {
1919
+ title: "Get Post Comments",
1920
+ readOnlyHint: true,
1921
+ openWorldHint: true
1922
+ },
1435
1923
  parameters: z.object({
1436
- post_id: z.string().describe("The Reddit post ID"),
1437
- subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
1924
+ post_id: z.string().describe("Base36 post id — the segment after /comments/ in a permalink (e.g. '1abc23'). With or without a t3_ prefix."),
1925
+ subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'movies')."),
1438
1926
  sort: z.enum([
1439
1927
  "best",
1440
1928
  "top",
@@ -1442,8 +1930,8 @@ server.addTool({
1442
1930
  "controversial",
1443
1931
  "old",
1444
1932
  "qa"
1445
- ]).default("best").describe("Comment sort order"),
1446
- limit: z.number().min(1).max(500).default(100).describe("Maximum number of comments to retrieve")
1933
+ ]).default("best").describe("Comment ordering: 'best' (default), 'top', 'new', 'controversial', 'old', or 'qa' (Q&A)."),
1934
+ limit: z.number().min(1).max(500).default(100).describe("Maximum comments to return, 1–500 (default 100). Deeply nested replies may still be truncated as 'load more' stubs.")
1447
1935
  }),
1448
1936
  execute: async (args) => {
1449
1937
  const client = unwrapClient();
@@ -1477,6 +1965,38 @@ ${comment.body}
1477
1965
  });
1478
1966
  }
1479
1967
  });
1968
+ server.addTool({
1969
+ name: "get_more_comments",
1970
+ description: "Expand truncated 'load more comments' stubs in a thread. Read-only; works anonymously. Pass the post's link id and the comment ids from a 'more' node (surfaced by get_post_comments) to fetch those hidden comments; returns the expanded comments (author, body excerpt, score, link). Call get_post_comments first to obtain the thread and its 'more' node ids — do NOT invent ids.",
1971
+ annotations: {
1972
+ title: "Get More Comments",
1973
+ readOnlyHint: true,
1974
+ openWorldHint: true
1975
+ },
1976
+ parameters: z.object({
1977
+ link_id: z.string().describe("The parent post's link id (base36, with or without the t3_ prefix) that the stub belongs to."),
1978
+ comment_ids: z.array(z.string()).min(1).describe("Base36 comment ids to expand, taken from a 'more' node returned by get_post_comments (not arbitrary ids).")
1979
+ }),
1980
+ execute: async (args) => {
1981
+ return (await unwrapClient().getMoreComments(args.link_id, args.comment_ids)).fold((err) => {
1982
+ throw new Error(`Failed to expand comments: ${err.message}`);
1983
+ }, (comments) => {
1984
+ if (comments.length === 0) return "No additional comments were returned for those ids.";
1985
+ const commentList = comments.map((comment, index) => {
1986
+ const truncated = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
1987
+ const flags = [...comment.edited ? ["*(edited)*"] : [], ...comment.isSubmitter ? ["**OP**"] : []];
1988
+ return `### ${index + 1}. u/${comment.author} ${flags.join(" ")}
1989
+ > ${truncated}
1990
+
1991
+ - Score: ${comment.score.toLocaleString()}
1992
+ - Link: https://reddit.com${comment.permalink}`;
1993
+ }).join("\n\n");
1994
+ return `# Expanded Comments (${comments.length})
1995
+
1996
+ ${commentList}`;
1997
+ });
1998
+ }
1999
+ });
1480
2000
  async function main() {
1481
2001
  await setupRedditClient();
1482
2002
  const useHttp = process.env.TRANSPORT_TYPE === "httpStream" || process.env.TRANSPORT_TYPE === "http";