reddit-mcp-server 1.5.0 → 1.5.2

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
@@ -59,6 +59,21 @@ var NotFoundError = class extends RedditErrorBase {
59
59
  this.name = "NotFoundError";
60
60
  }
61
61
  };
62
+ /**
63
+ * Reddit refused the request at the network level rather than for this specific resource.
64
+ *
65
+ * Reddit 403s the unauthenticated JSON API from many IP ranges (datacenters, VPNs, flagged
66
+ * addresses), answering with an HTML block page instead of JSON. A bare `HttpError(403)` reads
67
+ * as "this subreddit is private", which sends people looking in the wrong place — the fix is to
68
+ * supply OAuth credentials, which also raises the rate limit from ~10 to 60+ req/min.
69
+ */
70
+ var NetworkBlockedError = class extends RedditErrorBase {
71
+ _tag = "NetworkBlockedError";
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = "NetworkBlockedError";
75
+ }
76
+ };
62
77
  /** Client-side input or safety-policy rejection (invalid sort, duplicate-content guard). */
63
78
  var ValidationError = class extends RedditErrorBase {
64
79
  _tag = "ValidationError";
@@ -90,6 +105,71 @@ function classifyRedditError(error, context) {
90
105
  return new UnknownError(Option(context).fold(() => error.message, (ctx) => `${ctx}: ${error.message}`), error);
91
106
  }
92
107
  //#endregion
108
+ //#region src/utils/reddit-identifiers.ts
109
+ /**
110
+ * Validation and normalization for Reddit identifiers that get interpolated into API paths.
111
+ *
112
+ * Every identifier reaching the client is model- or user-supplied. Interpolating one raw is a
113
+ * path-injection hole: URL parsing resolves dot segments, so a `subreddit` of `../../api/v1/me`
114
+ * escapes `/r/{sub}/about.json` and steers the OAuth bearer token to a different endpoint, and an
115
+ * embedded `?` or `&` injects query parameters into the request we build.
116
+ *
117
+ * So each identifier is normalized (stripping the `r/`, `/u/`, `t3_` prefixes people naturally
118
+ * type) and then checked against Reddit's own charset. Every value returned from this module
119
+ * matches `[A-Za-z0-9_+-]+`, which is already URL-path-safe — no percent-encoding is applied,
120
+ * because encoding the `+` in a multireddit (`r/science+space`) would break it.
121
+ */
122
+ const SUBREDDIT_PATTERN = /^(u_)?[A-Za-z0-9][A-Za-z0-9_]{1,20}$/;
123
+ const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{1,19}$/;
124
+ const THING_ID_PATTERN = /^[a-z0-9]{1,13}$/;
125
+ const stripLeading = (value, prefixes) => {
126
+ const lower = value.toLowerCase();
127
+ const matched = prefixes.find((prefix) => lower.startsWith(prefix));
128
+ return matched === void 0 ? value : value.slice(matched.length);
129
+ };
130
+ const bare = (value, prefixes) => stripLeading(stripLeading(value.trim(), ["/"]), prefixes).replace(/\/+$/, "");
131
+ /**
132
+ * Normalize a subreddit name for use in a path segment.
133
+ *
134
+ * Accepts `science`, `r/science`, `/r/science`, and `+`-joined multireddits (`science+space`).
135
+ * The empty string passes through unchanged — callers use it to mean "the home feed".
136
+ */
137
+ function normalizeSubreddit(input) {
138
+ const trimmed = input.trim();
139
+ if (trimmed === "") return "";
140
+ const segments = bare(trimmed, ["r/"]).split("+").map((segment) => segment.trim());
141
+ if (segments.find((segment) => !SUBREDDIT_PATTERN.test(segment)) !== void 0) throw new ValidationError(`Invalid subreddit name "${input}". Expected 2-21 characters of letters, digits, or underscores (e.g. "science").`);
142
+ return segments.join("+");
143
+ }
144
+ /** Normalize a username for use in a path segment. Accepts `spez`, `u/spez`, `/user/spez`. */
145
+ function normalizeUsername(input) {
146
+ const name = bare(input, ["user/", "u/"]);
147
+ if (!USERNAME_PATTERN.test(name)) throw new ValidationError(`Invalid Reddit username "${input}". Expected 2-20 characters of letters, digits, underscores, or hyphens (e.g. "spez").`);
148
+ return name;
149
+ }
150
+ /**
151
+ * Normalize a post or comment ID to its bare base36 form, dropping any `t1_`/`t3_` fullname
152
+ * prefix. Use this wherever the ID lands in a path segment or query value.
153
+ */
154
+ function normalizeThingId(input) {
155
+ const id = bare(input, [
156
+ "t1_",
157
+ "t3_",
158
+ "t4_",
159
+ "t5_"
160
+ ]).toLowerCase();
161
+ if (!THING_ID_PATTERN.test(id)) throw new ValidationError(`Invalid Reddit ID "${input}". Expected a base36 id such as "1abc2de", optionally prefixed with t1_ or t3_.`);
162
+ return id;
163
+ }
164
+ /**
165
+ * Normalize an ID to a Reddit fullname (`t3_1abc2de`), preserving an explicit `t1_`/`t3_` prefix
166
+ * and falling back to `defaultKind` when the caller passed a bare ID.
167
+ */
168
+ function normalizeFullname(input, defaultKind) {
169
+ const trimmed = input.trim().toLowerCase();
170
+ return `${trimmed.startsWith("t1_") ? "t1" : trimmed.startsWith("t3_") ? "t3" : defaultKind}_${normalizeThingId(input)}`;
171
+ }
172
+ //#endregion
93
173
  //#region src/client/response-cache.ts
94
174
  const SECOND = 1e3;
95
175
  var ResponseCache = class {
@@ -160,6 +240,10 @@ function listingCursor(data) {
160
240
  ...before
161
241
  };
162
242
  }
243
+ function headerValue(response, name) {
244
+ const headers = response.headers;
245
+ return (headers === null || headers === void 0 ? void 0 : headers.get(name)) ?? "";
246
+ }
163
247
  function parsePostData(post) {
164
248
  return {
165
249
  id: post.id,
@@ -254,6 +338,7 @@ var RedditClient = class {
254
338
  ...headers,
255
339
  Authorization: await this.reauthorize()
256
340
  }, path, 0) : first;
341
+ if (!requiresAuth && response.status === 403 && !headerValue(response, "content-type").includes("json")) throw new NetworkBlockedError("Reddit is blocking unauthenticated requests from this network (HTTP 403 with a block page). Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET to authenticate with OAuth, which also raises the rate limit from ~10 to 60+ requests/min. See https://www.reddit.com/prefs/apps to create an app.");
257
342
  if (cacheable && response.ok) {
258
343
  const text = await response.text();
259
344
  this.cache.set(url, text, response.status);
@@ -381,7 +466,7 @@ var RedditClient = class {
381
466
  async getUser(username) {
382
467
  const context = `Failed to get user info for ${username}`;
383
468
  return (await Try.async(async () => {
384
- const response = (await this.makeRequest(`/user/${username}/about.json`)).orThrow();
469
+ const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/about.json`)).orThrow();
385
470
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
386
471
  const { data } = await response.json();
387
472
  return {
@@ -431,14 +516,14 @@ var RedditClient = class {
431
516
  const { limit = 25, after } = options;
432
517
  const params = new URLSearchParams({ limit: limit.toString() });
433
518
  if (after !== void 0) params.set("after", after);
434
- return this.getUserContent(`/user/${this.username}/overview.json?${params}`, "Failed to get your overview");
519
+ return this.getUserContent(`/user/${encodeURIComponent(this.username)}/overview.json?${params}`, "Failed to get your overview");
435
520
  }
436
521
  async getMySaved(options = {}) {
437
522
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching saved content requires REDDIT_USERNAME"));
438
523
  const { limit = 25, after } = options;
439
524
  const params = new URLSearchParams({ limit: limit.toString() });
440
525
  if (after !== void 0) params.set("after", after);
441
- return this.getUserContent(`/user/${this.username}/saved.json?${params}`, "Failed to get saved content");
526
+ return this.getUserContent(`/user/${encodeURIComponent(this.username)}/saved.json?${params}`, "Failed to get saved content");
442
527
  }
443
528
  async getMe() {
444
529
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your account requires REDDIT_USERNAME"));
@@ -464,7 +549,7 @@ var RedditClient = class {
464
549
  async getSubredditInfo(subredditName) {
465
550
  const context = `Failed to get subreddit info for ${subredditName}`;
466
551
  return (await Try.async(async () => {
467
- const response = (await this.makeRequest(`/r/${subredditName}/about.json`)).orThrow();
552
+ const response = (await this.makeRequest(`/r/${normalizeSubreddit(subredditName)}/about.json`)).orThrow();
468
553
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
469
554
  const { data } = await response.json();
470
555
  return {
@@ -484,7 +569,7 @@ var RedditClient = class {
484
569
  async getSubredditRules(subreddit) {
485
570
  const context = `Failed to get rules for r/${subreddit}`;
486
571
  return (await Try.async(async () => {
487
- const response = (await this.makeRequest(`/r/${subreddit}/about/rules.json`)).orThrow();
572
+ const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/about/rules.json`)).orThrow();
488
573
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
489
574
  return (await response.json()).rules.map((rule) => ({
490
575
  shortName: rule.short_name,
@@ -499,7 +584,7 @@ var RedditClient = class {
499
584
  async getPostFlairs(subreddit) {
500
585
  const context = `Failed to get post flairs for r/${subreddit}`;
501
586
  return (await Try.async(async () => {
502
- const response = (await this.makeRequest(`/r/${subreddit}/api/link_flair_v2.json`)).orThrow();
587
+ const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/api/link_flair_v2.json`)).orThrow();
503
588
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
504
589
  return (await response.json()).map((flair) => ({
505
590
  id: flair.id,
@@ -510,7 +595,6 @@ var RedditClient = class {
510
595
  })).toEither((error) => classifyRedditError(error, context));
511
596
  }
512
597
  async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
513
- const endpoint = subreddit !== "" ? `/r/${subreddit}/top.json` : "/top.json";
514
598
  const params = new URLSearchParams({
515
599
  t: timeFilter,
516
600
  limit: limit.toString()
@@ -518,6 +602,8 @@ var RedditClient = class {
518
602
  if (after !== void 0) params.set("after", after);
519
603
  const context = `Failed to get top posts for ${subreddit !== "" ? subreddit : "home"}`;
520
604
  return (await Try.async(async () => {
605
+ const name = normalizeSubreddit(subreddit);
606
+ const endpoint = name !== "" ? `/r/${name}/top.json` : "/top.json";
521
607
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
522
608
  if (!response.ok) throw new HttpError(response.status, `Failed to get top posts: HTTP ${response.status}`);
523
609
  const json = await response.json();
@@ -536,13 +622,14 @@ var RedditClient = class {
536
622
  "controversial"
537
623
  ];
538
624
  if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
539
- const endpoint = subreddit !== "" ? `/r/${subreddit}/${sort}.json` : `/${sort}.json`;
540
625
  const params = new URLSearchParams({ limit: limit.toString() });
541
626
  if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
542
627
  if (after !== void 0) params.set("after", after);
543
628
  const home = subreddit !== "" ? subreddit : "home";
544
629
  const context = `Failed to browse r/${home} (${sort})`;
545
630
  return (await Try.async(async () => {
631
+ const name = normalizeSubreddit(subreddit);
632
+ const endpoint = name !== "" ? `/r/${name}/${sort}.json` : `/${sort}.json`;
546
633
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
547
634
  if (!response.ok) throw new HttpError(response.status, `Failed to browse r/${home}: HTTP ${response.status}`);
548
635
  const json = await response.json();
@@ -553,9 +640,10 @@ var RedditClient = class {
553
640
  })).toEither((error) => classifyRedditError(error, context));
554
641
  }
555
642
  async getPost(postId, subreddit) {
556
- const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${postId}`, (sr) => `/r/${sr}/comments/${postId}.json`);
557
643
  const context = `Failed to get post with ID ${postId}`;
558
644
  return (await Try.async(async () => {
645
+ const id = normalizeThingId(postId);
646
+ const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${id}`, (sr) => `/r/${normalizeSubreddit(sr)}/comments/${id}.json`);
559
647
  const response = (await this.makeRequest(endpoint)).orThrow();
560
648
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
561
649
  if (subreddit !== void 0) return parsePostData((await response.json())[0].data.children[0].data);
@@ -579,10 +667,12 @@ var RedditClient = class {
579
667
  this.validateWriteAccess();
580
668
  await this.enforceWriteRateLimit();
581
669
  this.checkDuplicateContent(title + content, subreddit);
670
+ const targetSubreddit = normalizeSubreddit(subreddit);
671
+ if (targetSubreddit === "") throw new ValidationError("A subreddit is required to create a post.");
582
672
  const finalContent = isSelf ? this.appendBotDisclosure(content) : content;
583
673
  const kind = isSelf ? "self" : "link";
584
674
  const params = new URLSearchParams();
585
- params.append("sr", subreddit);
675
+ params.append("sr", targetSubreddit);
586
676
  params.append("kind", kind);
587
677
  params.append("title", title);
588
678
  params.append(isSelf ? "text" : "url", finalContent);
@@ -599,12 +689,12 @@ var RedditClient = class {
599
689
  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(", ")}`);
600
690
  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_", ""));
601
691
  if (postId === void 0) throw new ApiError("No post ID returned from Reddit");
602
- return (await this.getPost(postId, subreddit)).orThrow();
692
+ return (await this.getPost(postId, targetSubreddit)).orThrow();
603
693
  })).toEither((error) => classifyRedditError(error));
604
694
  }
605
695
  async checkPostExists(postId) {
606
696
  return (await Try.async(async () => {
607
- const response = (await this.makeRequest(`/api/info.json?id=t3_${postId}`)).orThrow();
697
+ const response = (await this.makeRequest(`/api/info.json?id=t3_${normalizeThingId(postId)}`)).orThrow();
608
698
  if (!response.ok) return false;
609
699
  return (await response.json()).data.children.length > 0;
610
700
  })).orElse(false);
@@ -616,9 +706,9 @@ var RedditClient = class {
616
706
  await this.enforceWriteRateLimit();
617
707
  this.checkDuplicateContent(content);
618
708
  const finalContent = this.appendBotDisclosure(content);
619
- const fullThingId = postId.startsWith("t3_") || postId.startsWith("t1_") ? postId : `t3_${postId}`;
620
- if (!postId.startsWith("t1_")) {
621
- if (!await this.checkPostExists(postId.replace(/^t3_/, ""))) throw new NotFoundError(`Post with ID ${postId} does not exist or is not accessible`);
709
+ const fullThingId = normalizeFullname(postId, "t3");
710
+ if (!fullThingId.startsWith("t1_")) {
711
+ if (!await this.checkPostExists(normalizeThingId(postId))) throw new NotFoundError(`Post with ID ${postId} does not exist or is not accessible`);
622
712
  }
623
713
  const params = new URLSearchParams();
624
714
  params.append("thing_id", fullThingId);
@@ -651,10 +741,10 @@ var RedditClient = class {
651
741
  else throw new ApiError("Failed to parse reply response");
652
742
  })).toEither((error) => classifyRedditError(error));
653
743
  }
654
- async deletePost(thingId) {
744
+ async deleteThing(thingId, defaultKind) {
655
745
  return (await Try.async(async () => {
656
746
  this.validateWriteAccess();
657
- const fullThingId = thingId.startsWith("t3_") || thingId.startsWith("t1_") ? thingId : `t3_${thingId}`;
747
+ const fullThingId = normalizeFullname(thingId, defaultKind);
658
748
  const params = new URLSearchParams();
659
749
  params.append("id", fullThingId);
660
750
  const response = (await this.makeRequest("/api/del", {
@@ -675,17 +765,19 @@ var RedditClient = class {
675
765
  return classifyRedditError(error);
676
766
  });
677
767
  }
768
+ async deletePost(thingId) {
769
+ return this.deleteThing(thingId, "t3");
770
+ }
678
771
  async deleteComment(thingId) {
679
- const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
680
- return this.deletePost(fullThingId);
772
+ return this.deleteThing(thingId, "t1");
681
773
  }
682
- async editPost(thingId, newText) {
774
+ async editThing(thingId, newText, defaultKind) {
683
775
  return (await Try.async(async () => {
684
776
  this.validateWriteAccess();
685
777
  await this.enforceWriteRateLimit();
686
778
  this.checkDuplicateContent(newText);
687
779
  const finalText = this.appendBotDisclosure(newText);
688
- const fullThingId = thingId.startsWith("t3_") || thingId.startsWith("t1_") ? thingId : `t3_${thingId}`;
780
+ const fullThingId = normalizeFullname(thingId, defaultKind);
689
781
  const params = new URLSearchParams();
690
782
  params.append("thing_id", fullThingId);
691
783
  params.append("text", finalText);
@@ -701,13 +793,14 @@ var RedditClient = class {
701
793
  return true;
702
794
  })).toEither((error) => classifyRedditError(error));
703
795
  }
796
+ async editPost(thingId, newText) {
797
+ return this.editThing(thingId, newText, "t3");
798
+ }
704
799
  async editComment(thingId, newText) {
705
- const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
706
- return this.editPost(fullThingId, newText);
800
+ return this.editThing(thingId, newText, "t1");
707
801
  }
708
802
  async searchReddit(query, options = {}) {
709
803
  const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link", after, before } = options;
710
- const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${sr}/search.json`);
711
804
  const params = new URLSearchParams({
712
805
  q: query,
713
806
  sort,
@@ -720,6 +813,7 @@ var RedditClient = class {
720
813
  });
721
814
  const context = `Failed to search Reddit for: ${query}`;
722
815
  return (await Try.async(async () => {
816
+ const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${normalizeSubreddit(sr)}/search.json`);
723
817
  const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
724
818
  if (!response.ok) throw new HttpError(response.status, `Failed to search Reddit: HTTP ${response.status}`);
725
819
  const json = await response.json();
@@ -737,7 +831,7 @@ var RedditClient = class {
737
831
  });
738
832
  const context = `Failed to get comments for post ${postId}`;
739
833
  return (await Try.async(async () => {
740
- const response = (await this.makeRequest(`/r/${subreddit}/comments/${postId}.json?${params}`)).orThrow();
834
+ const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/comments/${normalizeThingId(postId)}.json?${params}`)).orThrow();
741
835
  if (!response.ok) throw new HttpError(response.status, `Failed to get comments: HTTP ${response.status}`);
742
836
  const json = await response.json();
743
837
  const postData = json[0].data.children[0].data;
@@ -769,15 +863,14 @@ var RedditClient = class {
769
863
  })).toEither((error) => classifyRedditError(error, context));
770
864
  }
771
865
  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
- });
866
+ const context = `Failed to expand comments for ${linkId}`;
779
867
  return (await Try.async(async () => {
780
868
  var _json$json$data4;
869
+ const params = new URLSearchParams({
870
+ api_type: "json",
871
+ link_id: normalizeFullname(linkId, "t3"),
872
+ children: commentIds.map((id) => normalizeThingId(id)).join(",")
873
+ });
781
874
  const response = (await this.makeRequest(`/api/morechildren?${params}`)).orThrow();
782
875
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
783
876
  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) => {
@@ -809,7 +902,7 @@ var RedditClient = class {
809
902
  if (after !== void 0) params.set("after", after);
810
903
  const context = `Failed to get posts for user ${username}`;
811
904
  return (await Try.async(async () => {
812
- const response = (await this.makeRequest(`/user/${username}/submitted.json?${params}`)).orThrow();
905
+ const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/submitted.json?${params}`)).orThrow();
813
906
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
814
907
  const json = await response.json();
815
908
  return {
@@ -828,7 +921,7 @@ var RedditClient = class {
828
921
  if (after !== void 0) params.set("after", after);
829
922
  const context = `Failed to get comments for user ${username}`;
830
923
  return (await Try.async(async () => {
831
- const response = (await this.makeRequest(`/user/${username}/comments.json?${params}`)).orThrow();
924
+ const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/comments.json?${params}`)).orThrow();
832
925
  if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
833
926
  const json = await response.json();
834
927
  return {
@@ -872,7 +965,7 @@ function formatTimestamp(timestamp) {
872
965
  function analyzeUserActivity(karmaRatio, isMod, accountAgeDays) {
873
966
  return [
874
967
  ...karmaRatio > 5 ? ["Primarily a commenter, highly engaged in discussions"] : karmaRatio < .2 ? ["Content creator, focuses on sharing posts"] : ["Balanced participation in both posting and commenting"],
875
- ...accountAgeDays < 30 ? ["New user, still exploring Reddit"] : accountAgeDays > 365 * 5 ? ["Long-time Redditor with extensive platform experience"] : [],
968
+ ...accountAgeDays < 30 ? ["New user, still exploring Reddit"] : accountAgeDays > 1825 ? ["Long-time Redditor with extensive platform experience"] : [],
876
969
  ...isMod ? ["Community leader who helps maintain subreddit quality"] : []
877
970
  ].join("\n - ");
878
971
  }
@@ -882,7 +975,7 @@ function analyzePostEngagement(score, ratio, numComments) {
882
975
  function analyzeSubredditHealth(subscribers, activeUsers, ageDays) {
883
976
  const sizeInsights = subscribers > 1e6 ? ["Major subreddit with massive following"] : subscribers > 1e5 ? ["Well-established community"] : subscribers < 1e3 ? ["Niche community, potential for growth"] : [];
884
977
  const activityInsights = activeUsers.map((active) => active / subscribers).fold(() => [], (activityRatio) => activityRatio > .1 ? ["Highly active community with strong engagement"] : activityRatio < .01 ? ["Could benefit from more community engagement initiatives"] : []);
885
- const ageInsights = ageDays > 365 * 5 ? ["Mature subreddit with established culture"] : ageDays < 90 ? ["New subreddit still forming its community"] : [];
978
+ const ageInsights = ageDays > 1825 ? ["Mature subreddit with established culture"] : ageDays < 90 ? ["New subreddit still forming its community"] : [];
886
979
  return [
887
980
  ...sizeInsights,
888
981
  ...activityInsights,
@@ -910,7 +1003,7 @@ function getSubredditEngagementTips(subreddit) {
910
1003
  return allTips.length > 0 ? allTips.join("\n - ") : "Regular engagement recommended to maintain community presence";
911
1004
  }
912
1005
  function formatUserInfo(user) {
913
- const accountAgeDays = (Date.now() / 1e3 - user.createdUtc) / (24 * 3600);
1006
+ const accountAgeDays = (Date.now() / 1e3 - user.createdUtc) / 86400;
914
1007
  const karmaRatio = user.commentKarma / (user.linkKarma === 0 ? 1 : user.linkKarma);
915
1008
  const status = [
916
1009
  ...user.isMod ? ["Moderator"] : [],
@@ -965,7 +1058,7 @@ function formatPostInfo(post) {
965
1058
  }
966
1059
  function formatSubredditInfo(subreddit) {
967
1060
  const flags = [...subreddit.over18 ? ["NSFW"] : [], ...Option(subreddit.subredditType).fold(() => [], (type) => [`Type: ${type}`])];
968
- const ageDays = (Date.now() / 1e3 - subreddit.createdUtc) / (24 * 3600);
1061
+ const ageDays = (Date.now() / 1e3 - subreddit.createdUtc) / 86400;
969
1062
  return {
970
1063
  name: subreddit.displayName,
971
1064
  title: subreddit.title,
@@ -992,7 +1085,7 @@ function formatSubredditInfo(subreddit) {
992
1085
  //#endregion
993
1086
  //#region src/index.ts
994
1087
  dotenv.config({ quiet: true });
995
- const VERSION = "1.5.0";
1088
+ const VERSION = "1.5.2";
996
1089
  function validateUserAgent(userAgent, username) {
997
1090
  if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
998
1091
  console.error("[Warning] User-Agent does not follow Reddit's recommended format");
@@ -1193,7 +1286,12 @@ For details: https://support.reddithelp.com/hc/en-us/articles/42728983564564-Res
1193
1286
  });
1194
1287
  server.addTool({
1195
1288
  name: "test_reddit_mcp_server",
1196
- description: "Test the Reddit MCP Server connection and configuration",
1289
+ 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.",
1290
+ annotations: {
1291
+ title: "Test Reddit MCP Server",
1292
+ readOnlyHint: true,
1293
+ openWorldHint: false
1294
+ },
1197
1295
  parameters: z.object({}),
1198
1296
  execute: () => {
1199
1297
  const client = getRedditClient();
@@ -1210,8 +1308,13 @@ Ready to handle Reddit API requests!`);
1210
1308
  });
1211
1309
  server.addTool({
1212
1310
  name: "get_user_info",
1213
- description: "Get detailed information about a Reddit user including karma, account status, and activity analysis",
1214
- parameters: z.object({ username: z.string().describe("The Reddit username (without u/ prefix)") }),
1311
+ 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.",
1312
+ annotations: {
1313
+ title: "Get User Info",
1314
+ readOnlyHint: true,
1315
+ openWorldHint: true
1316
+ },
1317
+ parameters: z.object({ username: z.string().describe("The target user's Reddit username, without the u/ prefix (e.g. 'spez', not 'u/spez').") }),
1215
1318
  execute: async (args) => {
1216
1319
  return (await unwrapClient().getUser(args.username)).fold((err) => {
1217
1320
  throw new Error(`Failed to get user info: ${err.message}`);
@@ -1239,7 +1342,12 @@ server.addTool({
1239
1342
  });
1240
1343
  server.addTool({
1241
1344
  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.",
1345
+ 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.",
1346
+ annotations: {
1347
+ title: "Get My Account",
1348
+ readOnlyHint: true,
1349
+ openWorldHint: true
1350
+ },
1243
1351
  parameters: z.object({}),
1244
1352
  execute: async () => {
1245
1353
  return (await unwrapClient().getMe()).fold((err) => {
@@ -1262,10 +1370,15 @@ server.addTool({
1262
1370
  });
1263
1371
  server.addTool({
1264
1372
  name: "get_my_overview",
1265
- description: "Get your own recent activity (posts and comments combined). Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD).",
1373
+ 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.",
1374
+ annotations: {
1375
+ title: "Get My Overview",
1376
+ readOnlyHint: true,
1377
+ openWorldHint: true
1378
+ },
1266
1379
  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")
1380
+ limit: z.number().min(1).max(100).default(25).describe("How many activity items to return, 1–100 (default 25)."),
1381
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
1269
1382
  }),
1270
1383
  execute: async (args) => {
1271
1384
  return (await unwrapClient().getMyOverview({
@@ -1278,10 +1391,15 @@ server.addTool({
1278
1391
  });
1279
1392
  server.addTool({
1280
1393
  name: "get_my_saved",
1281
- description: "Get your saved posts and comments. Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD); saved content is private.",
1394
+ 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.",
1395
+ annotations: {
1396
+ title: "Get My Saved",
1397
+ readOnlyHint: true,
1398
+ openWorldHint: true
1399
+ },
1282
1400
  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")
1401
+ limit: z.number().min(1).max(100).default(25).describe("How many saved items to return, 1–100 (default 25)."),
1402
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
1285
1403
  }),
1286
1404
  execute: async (args) => {
1287
1405
  return (await unwrapClient().getMySaved({
@@ -1294,14 +1412,19 @@ server.addTool({
1294
1412
  });
1295
1413
  server.addTool({
1296
1414
  name: "get_user_posts",
1297
- description: "Get recent posts by a Reddit user with sorting and filtering options",
1415
+ 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.",
1416
+ annotations: {
1417
+ title: "Get User Posts",
1418
+ readOnlyHint: true,
1419
+ openWorldHint: true
1420
+ },
1298
1421
  parameters: z.object({
1299
- username: z.string().describe("The Reddit username (without u/ prefix)"),
1422
+ username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
1300
1423
  sort: z.enum([
1301
1424
  "new",
1302
1425
  "hot",
1303
1426
  "top"
1304
- ]).default("new").describe("Sort order for posts"),
1427
+ ]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
1305
1428
  time_filter: z.enum([
1306
1429
  "hour",
1307
1430
  "day",
@@ -1309,9 +1432,9 @@ server.addTool({
1309
1432
  "month",
1310
1433
  "year",
1311
1434
  "all"
1312
- ]).default("all").describe("Time filter for top posts"),
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")
1435
+ ]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
1436
+ limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
1437
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1315
1438
  }),
1316
1439
  execute: async (args) => {
1317
1440
  return (await unwrapClient().getUserPosts(args.username, {
@@ -1341,14 +1464,19 @@ ${postSummaries}${nextPageHint(page.after)}`;
1341
1464
  });
1342
1465
  server.addTool({
1343
1466
  name: "get_user_comments",
1344
- description: "Get recent comments by a Reddit user with sorting and filtering options",
1467
+ 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.",
1468
+ annotations: {
1469
+ title: "Get User Comments",
1470
+ readOnlyHint: true,
1471
+ openWorldHint: true
1472
+ },
1345
1473
  parameters: z.object({
1346
- username: z.string().describe("The Reddit username (without u/ prefix)"),
1474
+ username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
1347
1475
  sort: z.enum([
1348
1476
  "new",
1349
1477
  "hot",
1350
1478
  "top"
1351
- ]).default("new").describe("Sort order for comments"),
1479
+ ]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
1352
1480
  time_filter: z.enum([
1353
1481
  "hour",
1354
1482
  "day",
@@ -1356,9 +1484,9 @@ server.addTool({
1356
1484
  "month",
1357
1485
  "year",
1358
1486
  "all"
1359
- ]).default("all").describe("Time filter for top comments"),
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")
1487
+ ]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
1488
+ limit: z.number().min(1).max(100).default(10).describe("How many comments to return, 1–100 (default 10)."),
1489
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1362
1490
  }),
1363
1491
  execute: async (args) => {
1364
1492
  return (await unwrapClient().getUserComments(args.username, {
@@ -1391,10 +1519,15 @@ ${commentSummaries}${nextPageHint(page.after)}`;
1391
1519
  });
1392
1520
  server.addTool({
1393
1521
  name: "get_reddit_post",
1394
- description: "Get detailed information about a specific Reddit post including content, stats, and engagement analysis",
1522
+ 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).",
1523
+ annotations: {
1524
+ title: "Get Reddit Post",
1525
+ readOnlyHint: true,
1526
+ openWorldHint: true
1527
+ },
1395
1528
  parameters: z.object({
1396
- subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
1397
- post_id: z.string().describe("The Reddit post ID")
1529
+ subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'programming')."),
1530
+ 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.")
1398
1531
  }),
1399
1532
  execute: async (args) => {
1400
1533
  return (await unwrapClient().getPost(args.post_id, args.subreddit)).fold((err) => {
@@ -1435,9 +1568,14 @@ ${formattedPost.bestTimeToEngage}`;
1435
1568
  });
1436
1569
  server.addTool({
1437
1570
  name: "get_top_posts",
1438
- description: "Get top posts from a subreddit or from the Reddit home feed",
1571
+ 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.",
1572
+ annotations: {
1573
+ title: "Get Top Posts",
1574
+ readOnlyHint: true,
1575
+ openWorldHint: true
1576
+ },
1439
1577
  parameters: z.object({
1440
- subreddit: z.string().optional().describe("The subreddit name (without r/ prefix). Leave empty for home feed"),
1578
+ subreddit: z.string().optional().describe("Subreddit to read, without the r/ prefix (e.g. 'science'). Omit to use the authenticated home feed (requires credentials)."),
1441
1579
  time_filter: z.enum([
1442
1580
  "hour",
1443
1581
  "day",
@@ -1445,9 +1583,9 @@ server.addTool({
1445
1583
  "month",
1446
1584
  "year",
1447
1585
  "all"
1448
- ]).default("week").describe("Time period for top posts"),
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")
1586
+ ]).default("week").describe("Time window the 'top' ranking is computed over (e.g. 'day' = top today). Default 'week'."),
1587
+ limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
1588
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1451
1589
  }),
1452
1590
  execute: async (args) => {
1453
1591
  return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit, args.after)).fold((err) => {
@@ -1469,16 +1607,21 @@ ${postSummaries}${nextPageHint(page.after)}`;
1469
1607
  });
1470
1608
  server.addTool({
1471
1609
  name: "browse_subreddit",
1472
- description: "Browse posts from a subreddit or the Reddit home feed with a sort order (hot, new, top, rising, controversial). The time_filter only applies to top and controversial sorts.",
1610
+ 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.",
1611
+ annotations: {
1612
+ title: "Browse Subreddit",
1613
+ readOnlyHint: true,
1614
+ openWorldHint: true
1615
+ },
1473
1616
  parameters: z.object({
1474
- subreddit: z.string().optional().describe("The subreddit name (without r/ prefix). Leave empty for home feed"),
1617
+ subreddit: z.string().optional().describe("Subreddit to browse, without the r/ prefix (e.g. 'news'). Omit to use the authenticated home feed (requires credentials)."),
1475
1618
  sort: z.enum([
1476
1619
  "hot",
1477
1620
  "new",
1478
1621
  "top",
1479
1622
  "rising",
1480
1623
  "controversial"
1481
- ]).default("hot").describe("Sort order for posts"),
1624
+ ]).default("hot").describe("Feed ordering: 'hot' (default), 'new', 'rising', 'top', or 'controversial'. 'top'/'controversial' honor `time_filter`."),
1482
1625
  time_filter: z.enum([
1483
1626
  "hour",
1484
1627
  "day",
@@ -1486,9 +1629,9 @@ server.addTool({
1486
1629
  "month",
1487
1630
  "year",
1488
1631
  "all"
1489
- ]).default("week").describe("Time period (only applies to top and controversial sorts)"),
1490
- limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
1491
- after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
1632
+ ]).default("week").describe("Time window; only applies to sort='top' or 'controversial'. Ignored otherwise. Default 'week'."),
1633
+ limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
1634
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1492
1635
  }),
1493
1636
  execute: async (args) => {
1494
1637
  return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
@@ -1513,8 +1656,13 @@ ${postSummaries}${nextPageHint(page.after)}`;
1513
1656
  });
1514
1657
  server.addTool({
1515
1658
  name: "get_subreddit_info",
1516
- description: "Get detailed information about a subreddit including description, stats, and community analysis",
1517
- parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
1659
+ 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'.",
1660
+ annotations: {
1661
+ title: "Get Subreddit Info",
1662
+ readOnlyHint: true,
1663
+ openWorldHint: true
1664
+ },
1665
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'askscience').") }),
1518
1666
  execute: async (args) => {
1519
1667
  return (await unwrapClient().getSubredditInfo(args.subreddit_name)).fold((err) => {
1520
1668
  throw new Error(`Failed to get subreddit info: ${err.message}`);
@@ -1552,8 +1700,13 @@ ${formattedSubreddit.description.full}
1552
1700
  });
1553
1701
  server.addTool({
1554
1702
  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)") }),
1703
+ 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.",
1704
+ annotations: {
1705
+ title: "Get Subreddit Rules",
1706
+ readOnlyHint: true,
1707
+ openWorldHint: true
1708
+ },
1709
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'AskReddit').") }),
1557
1710
  execute: async (args) => {
1558
1711
  return (await unwrapClient().getSubredditRules(args.subreddit_name)).fold((err) => {
1559
1712
  throw new Error(`Failed to get subreddit rules: ${err.message}`);
@@ -1572,8 +1725,13 @@ ${ruleList}`;
1572
1725
  });
1573
1726
  server.addTool({
1574
1727
  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)") }),
1728
+ 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.",
1729
+ annotations: {
1730
+ title: "Get Post Flairs",
1731
+ readOnlyHint: true,
1732
+ openWorldHint: true
1733
+ },
1734
+ parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'gadgets').") }),
1577
1735
  execute: async (args) => {
1578
1736
  return (await unwrapClient().getPostFlairs(args.subreddit_name)).fold((err) => {
1579
1737
  throw new Error(`Failed to get post flairs: ${err.message}`);
@@ -1593,7 +1751,12 @@ Pass the desired \`flair_id\` to \`create_post\`.`;
1593
1751
  });
1594
1752
  server.addTool({
1595
1753
  name: "get_trending_subreddits",
1596
- description: "Get a list of currently trending subreddits",
1754
+ 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'.",
1755
+ annotations: {
1756
+ title: "Get Trending Subreddits",
1757
+ readOnlyHint: true,
1758
+ openWorldHint: true
1759
+ },
1597
1760
  parameters: z.object({}),
1598
1761
  execute: async () => {
1599
1762
  return (await unwrapClient().getTrendingSubreddits()).fold((err) => {
@@ -1605,10 +1768,15 @@ ${trendingSubreddits.map((subreddit, index) => `${index + 1}. r/${subreddit}`).j
1605
1768
  });
1606
1769
  server.addTool({
1607
1770
  name: "search_reddit",
1608
- description: "Search Reddit for posts and content across subreddits",
1771
+ 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.",
1772
+ annotations: {
1773
+ title: "Search Reddit",
1774
+ readOnlyHint: true,
1775
+ openWorldHint: true
1776
+ },
1609
1777
  parameters: z.object({
1610
- query: z.string().describe("Search query"),
1611
- subreddit: z.string().optional().describe("Limit search to specific subreddit (without r/ prefix)"),
1778
+ query: z.string().describe("Search terms; supports Reddit operators (quotes for exact phrases, author:name, self:yes). Must be non-empty."),
1779
+ subreddit: z.string().optional().describe("Restrict results to this subreddit, without the r/ prefix (e.g. 'python'). Omit to search all of Reddit."),
1612
1780
  sort: z.enum([
1613
1781
  "relevance",
1614
1782
  "hot",
@@ -1623,14 +1791,14 @@ server.addTool({
1623
1791
  "month",
1624
1792
  "year",
1625
1793
  "all"
1626
- ]).default("all").describe("Time filter"),
1627
- limit: z.number().min(1).max(100).default(10).describe("Number of results"),
1794
+ ]).default("all").describe("Restrict to results from this recent window (e.g. 'week'). Default 'all' (no time limit)."),
1795
+ limit: z.number().min(1).max(100).default(10).describe("How many results to return, 1–100 (default 10)."),
1628
1796
  type: z.enum([
1629
1797
  "link",
1630
1798
  "sr",
1631
1799
  "user"
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")
1800
+ ]).default("link").describe("What to search for: 'link' = posts (default), 'sr' = subreddits, 'user' = users."),
1801
+ after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
1634
1802
  }),
1635
1803
  execute: async (args) => {
1636
1804
  const client = unwrapClient();
@@ -1671,14 +1839,21 @@ ${searchResults}${nextPageHint(page.after)}`;
1671
1839
  });
1672
1840
  server.addTool({
1673
1841
  name: "create_post",
1674
- 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.",
1842
+ 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.",
1843
+ annotations: {
1844
+ title: "Create Post",
1845
+ readOnlyHint: false,
1846
+ destructiveHint: false,
1847
+ idempotentHint: false,
1848
+ openWorldHint: true
1849
+ },
1675
1850
  parameters: z.object({
1676
- subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
1677
- title: z.string().describe("The post title"),
1678
- content: z.string().describe("The post content (text for self posts, URL for link posts)"),
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")
1851
+ subreddit: z.string().describe("Target subreddit, without the r/ prefix (e.g. 'test')."),
1852
+ title: z.string().describe("Post title (cannot be edited after creation)."),
1853
+ 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."),
1854
+ 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."),
1855
+ flair_id: z.string().optional().describe("Link flair template id from get_post_flairs; many subreddits require one or the post is auto-removed."),
1856
+ flair_text: z.string().optional().describe("Custom flair text, allowed only for flairs whose template is text-editable.")
1682
1857
  }),
1683
1858
  execute: async (args) => {
1684
1859
  const client = unwrapClient();
@@ -1701,10 +1876,17 @@ Your post has been successfully submitted to r/${formattedPost.subreddit}.`;
1701
1876
  });
1702
1877
  server.addTool({
1703
1878
  name: "reply_to_post",
1704
- 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.",
1879
+ 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.",
1880
+ annotations: {
1881
+ title: "Reply to Post or Comment",
1882
+ readOnlyHint: false,
1883
+ destructiveHint: false,
1884
+ idempotentHint: false,
1885
+ openWorldHint: true
1886
+ },
1705
1887
  parameters: z.object({
1706
- post_id: z.string().describe("The Reddit post ID (thing_id, e.g., t3_xxxxx for posts, t1_xxxxx for comments)"),
1707
- content: z.string().describe("The reply content")
1888
+ 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)."),
1889
+ content: z.string().describe("Reply body text; Reddit markdown supported.")
1708
1890
  }),
1709
1891
  execute: async (args) => {
1710
1892
  const client = unwrapClient();
@@ -1723,8 +1905,15 @@ Your reply has been successfully posted.`);
1723
1905
  });
1724
1906
  server.addTool({
1725
1907
  name: "delete_post",
1726
- description: "Delete your own Reddit post (requires REDDIT_USERNAME and REDDIT_PASSWORD). WARNING: This action is permanent and cannot be undone!",
1727
- 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.") }),
1908
+ 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.",
1909
+ annotations: {
1910
+ title: "Delete Post",
1911
+ readOnlyHint: false,
1912
+ destructiveHint: true,
1913
+ idempotentHint: true,
1914
+ openWorldHint: true
1915
+ },
1916
+ 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.") }),
1728
1917
  execute: async (args) => {
1729
1918
  const client = unwrapClient();
1730
1919
  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.");
@@ -1739,8 +1928,15 @@ The post ${args.thing_id} has been permanently deleted from Reddit.
1739
1928
  });
1740
1929
  server.addTool({
1741
1930
  name: "delete_comment",
1742
- description: "Delete your own Reddit comment (requires REDDIT_USERNAME and REDDIT_PASSWORD). WARNING: This action is permanent and cannot be undone!",
1743
- 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.") }),
1931
+ 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.",
1932
+ annotations: {
1933
+ title: "Delete Comment",
1934
+ readOnlyHint: false,
1935
+ destructiveHint: true,
1936
+ idempotentHint: true,
1937
+ openWorldHint: true
1938
+ },
1939
+ 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.") }),
1744
1940
  execute: async (args) => {
1745
1941
  const client = unwrapClient();
1746
1942
  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.");
@@ -1755,10 +1951,17 @@ The comment ${args.thing_id} has been permanently deleted from Reddit.
1755
1951
  });
1756
1952
  server.addTool({
1757
1953
  name: "edit_post",
1758
- 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.",
1954
+ 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.",
1955
+ annotations: {
1956
+ title: "Edit Post",
1957
+ readOnlyHint: false,
1958
+ destructiveHint: true,
1959
+ idempotentHint: true,
1960
+ openWorldHint: true
1961
+ },
1759
1962
  parameters: z.object({
1760
- 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."),
1761
- new_text: z.string().describe("The new text content for the post. Supports Reddit markdown formatting.")
1963
+ 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."),
1964
+ new_text: z.string().describe("Replacement body text; fully overwrites the current body. Reddit markdown supported.")
1762
1965
  }),
1763
1966
  execute: async (args) => {
1764
1967
  const client = unwrapClient();
@@ -1778,10 +1981,17 @@ The post ${args.thing_id} has been updated with your new content.
1778
1981
  });
1779
1982
  server.addTool({
1780
1983
  name: "edit_comment",
1781
- 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.",
1984
+ 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.",
1985
+ annotations: {
1986
+ title: "Edit Comment",
1987
+ readOnlyHint: false,
1988
+ destructiveHint: true,
1989
+ idempotentHint: true,
1990
+ openWorldHint: true
1991
+ },
1782
1992
  parameters: z.object({
1783
- 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."),
1784
- new_text: z.string().describe("The new text content for the comment. Supports Reddit markdown formatting.")
1993
+ 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."),
1994
+ new_text: z.string().describe("Replacement comment text; fully overwrites the current content. Reddit markdown supported.")
1785
1995
  }),
1786
1996
  execute: async (args) => {
1787
1997
  const client = unwrapClient();
@@ -1797,10 +2007,15 @@ The comment ${args.thing_id} has been updated with your new content.
1797
2007
  });
1798
2008
  server.addTool({
1799
2009
  name: "get_post_comments",
1800
- description: "Get comments from a specific Reddit post",
2010
+ 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.",
2011
+ annotations: {
2012
+ title: "Get Post Comments",
2013
+ readOnlyHint: true,
2014
+ openWorldHint: true
2015
+ },
1801
2016
  parameters: z.object({
1802
- post_id: z.string().describe("The Reddit post ID"),
1803
- subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
2017
+ post_id: z.string().describe("Base36 post id — the segment after /comments/ in a permalink (e.g. '1abc23'). With or without a t3_ prefix."),
2018
+ subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'movies')."),
1804
2019
  sort: z.enum([
1805
2020
  "best",
1806
2021
  "top",
@@ -1808,8 +2023,8 @@ server.addTool({
1808
2023
  "controversial",
1809
2024
  "old",
1810
2025
  "qa"
1811
- ]).default("best").describe("Comment sort order"),
1812
- limit: z.number().min(1).max(500).default(100).describe("Maximum number of comments to retrieve")
2026
+ ]).default("best").describe("Comment ordering: 'best' (default), 'top', 'new', 'controversial', 'old', or 'qa' (Q&A)."),
2027
+ 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.")
1813
2028
  }),
1814
2029
  execute: async (args) => {
1815
2030
  const client = unwrapClient();
@@ -1845,10 +2060,15 @@ ${comment.body}
1845
2060
  });
1846
2061
  server.addTool({
1847
2062
  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.",
2063
+ 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.",
2064
+ annotations: {
2065
+ title: "Get More Comments",
2066
+ readOnlyHint: true,
2067
+ openWorldHint: true
2068
+ },
1849
2069
  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)")
2070
+ link_id: z.string().describe("The parent post's link id (base36, with or without the t3_ prefix) that the stub belongs to."),
2071
+ 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).")
1852
2072
  }),
1853
2073
  execute: async (args) => {
1854
2074
  return (await unwrapClient().getMoreComments(args.link_id, args.comment_ids)).fold((err) => {