reddit-mcp-server 1.5.3 → 1.6.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,7 @@ 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
+ import { XMLParser } from "fast-xml-parser";
6
7
  //#region src/client/errors.ts
7
8
  /**
8
9
  * Typed error channel for the Reddit client.
@@ -59,21 +60,6 @@ var NotFoundError = class extends RedditErrorBase {
59
60
  this.name = "NotFoundError";
60
61
  }
61
62
  };
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
- };
77
63
  /** Client-side input or safety-policy rejection (invalid sort, duplicate-content guard). */
78
64
  var ValidationError = class extends RedditErrorBase {
79
65
  _tag = "ValidationError";
@@ -231,6 +217,100 @@ var ResponseCache = class {
231
217
  }
232
218
  };
233
219
  //#endregion
220
+ //#region src/client/rss-client.ts
221
+ function contentText(content) {
222
+ if (content === void 0) return "";
223
+ if (typeof content === "string") return content;
224
+ return content["#text"] ?? "";
225
+ }
226
+ const parser = new XMLParser({
227
+ ignoreAttributes: false,
228
+ removeNSPrefix: true,
229
+ parseTagValue: false
230
+ });
231
+ function parseAtomFeed(xml) {
232
+ var _result$feed;
233
+ const entries = (_result$feed = parser.parse(xml).feed) === null || _result$feed === void 0 ? void 0 : _result$feed.entry;
234
+ if (!entries) return [];
235
+ return Array.isArray(entries) ? entries : [entries];
236
+ }
237
+ function decodeHtmlEntities(text) {
238
+ return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&#32;/g, " ").replace(/&amp;/g, "&");
239
+ }
240
+ function extractLinkUrl(contentHtml) {
241
+ const match = contentHtml.match(/href="([^"]+)">\[link\]/);
242
+ return (match === null || match === void 0 ? void 0 : match[1]) ? decodeHtmlEntities(match[1]) : void 0;
243
+ }
244
+ function extractSelfText(contentHtml) {
245
+ const match = contentHtml.match(/<!-- SC_OFF -->(.*?)<!-- SC_ON -->/s);
246
+ if (!match) return "";
247
+ return decodeHtmlEntities(match[1].replace(/<[^>]+>/g, "")).trim();
248
+ }
249
+ function atomEntryToRedditPost(entry) {
250
+ var _entry$link, _entry$author, _entry$category;
251
+ const permalink = ((_entry$link = entry.link) === null || _entry$link === void 0 ? void 0 : _entry$link["@_href"]) ?? "";
252
+ const contentHtml = contentText(entry.content);
253
+ const externalUrl = extractLinkUrl(contentHtml);
254
+ const isSelf = !externalUrl || externalUrl.includes("/comments/");
255
+ const author = (((_entry$author = entry.author) === null || _entry$author === void 0 ? void 0 : _entry$author.name) ?? "").replace(/^\/u\//, "");
256
+ const subreddit = ((_entry$category = entry.category) === null || _entry$category === void 0 ? void 0 : _entry$category["@_term"]) ?? "";
257
+ return {
258
+ id: (entry.id ?? "").replace(/^t3_/, ""),
259
+ title: entry.title ?? "",
260
+ author,
261
+ subreddit,
262
+ selftext: extractSelfText(contentHtml),
263
+ url: isSelf ? permalink : externalUrl ?? permalink,
264
+ score: 0,
265
+ upvoteRatio: 0,
266
+ numComments: 0,
267
+ createdUtc: Math.floor(new Date(entry.published ?? entry.updated ?? "").getTime() / 1e3),
268
+ over18: false,
269
+ edited: false,
270
+ isSelf,
271
+ permalink: permalink.replace("https://www.reddit.com", "")
272
+ };
273
+ }
274
+ const RSS_CACHE_TTL_MS = 6e4;
275
+ var RssClient = class {
276
+ userAgent;
277
+ cache = /* @__PURE__ */ new Map();
278
+ constructor(userAgent) {
279
+ this.userAgent = userAgent;
280
+ }
281
+ async fetchSubredditPosts(subreddit, sort, timeFilter, limit, after) {
282
+ return (await Try.async(async () => {
283
+ const sub = normalizeSubreddit(subreddit);
284
+ const basePath = sub === "" ? "" : `/r/${sub}`;
285
+ const sortPath = sort === "hot" ? "" : `/${sort}`;
286
+ const params = new URLSearchParams();
287
+ if (timeFilter && (sort === "top" || sort === "controversial")) params.set("t", timeFilter);
288
+ if (limit !== void 0) params.set("limit", String(limit));
289
+ if (after !== void 0) params.set("after", after);
290
+ const url = `https://www.reddit.com${basePath}${sortPath}/.rss${params.size > 0 ? `?${params}` : ""}`;
291
+ const cached = this.cache.get(url);
292
+ if (cached && Date.now() < cached.expiresAt) return cached.page;
293
+ const response = await fetch(url, { headers: { "User-Agent": this.userAgent } });
294
+ if (response.status === 429) throw new HttpError(429, "RSS rate limit exceeded (~1 req/min for unauthenticated feeds). Try again shortly.");
295
+ if (!response.ok) throw new HttpError(response.status, `RSS feed request failed: ${response.status} ${response.statusText}`);
296
+ const allItems = parseAtomFeed(await response.text()).map(atomEntryToRedditPost);
297
+ const cap = limit ?? 25;
298
+ const items = allItems.length > cap ? allItems.slice(0, cap) : allItems;
299
+ const afterCursor = allItems.length > items.length ? `t3_${items[items.length - 1].id}` : void 0;
300
+ const page = {
301
+ items,
302
+ source: "rss",
303
+ ...afterCursor ? { after: afterCursor } : {}
304
+ };
305
+ this.cache.set(url, {
306
+ page,
307
+ expiresAt: Date.now() + RSS_CACHE_TTL_MS
308
+ });
309
+ return page;
310
+ })).toEither((error) => classifyRedditError(error, "RSS fetch"));
311
+ }
312
+ };
313
+ //#endregion
234
314
  //#region src/client/reddit-client.ts
235
315
  function listingCursor(data) {
236
316
  const after = typeof data.after === "string" ? { after: data.after } : {};
@@ -240,10 +320,6 @@ function listingCursor(data) {
240
320
  ...before
241
321
  };
242
322
  }
243
- function headerValue(response, name) {
244
- const headers = response.headers;
245
- return (headers === null || headers === void 0 ? void 0 : headers.get(name)) ?? "";
246
- }
247
323
  function parsePostData(post) {
248
324
  return {
249
325
  id: post.id,
@@ -264,6 +340,42 @@ function parsePostData(post) {
264
340
  permalink: post.permalink
265
341
  };
266
342
  }
343
+ function parseSubredditSearchData(sub) {
344
+ return {
345
+ id: String(sub.id ?? sub.display_name),
346
+ title: `r/${sub.display_name} (${sub.subscribers.toLocaleString("en-US")} subscribers)${sub.title ? ` — ${sub.title}` : ""}`,
347
+ author: "",
348
+ subreddit: sub.display_name,
349
+ selftext: sub.public_description,
350
+ url: `https://reddit.com${sub.url}`,
351
+ score: sub.subscribers,
352
+ upvoteRatio: 1,
353
+ numComments: 0,
354
+ createdUtc: sub.created_utc,
355
+ over18: sub.over18,
356
+ edited: false,
357
+ isSelf: false,
358
+ permalink: sub.url
359
+ };
360
+ }
361
+ function parseUserSearchData(user) {
362
+ const karma = user.total_karma ?? user.link_karma + user.comment_karma;
363
+ return {
364
+ id: user.id,
365
+ title: `u/${user.name} (${karma.toLocaleString("en-US")} karma)`,
366
+ author: user.name,
367
+ subreddit: "",
368
+ url: `https://reddit.com/user/${user.name}`,
369
+ score: karma,
370
+ upvoteRatio: 1,
371
+ numComments: 0,
372
+ createdUtc: user.created_utc,
373
+ over18: false,
374
+ edited: false,
375
+ isSelf: false,
376
+ permalink: `/user/${user.name}`
377
+ };
378
+ }
267
379
  var RedditClient = class {
268
380
  clientId;
269
381
  clientSecret;
@@ -277,6 +389,8 @@ var RedditClient = class {
277
389
  botDisclosure;
278
390
  cache;
279
391
  retry;
392
+ rssClient;
393
+ usesRss;
280
394
  accessToken;
281
395
  tokenExpiry = 0;
282
396
  authenticated = false;
@@ -309,6 +423,8 @@ var RedditClient = class {
309
423
  baseDelayMs: 1e3,
310
424
  maxDelayMs: 6e4
311
425
  };
426
+ this.usesRss = this.authMode === "anonymous" || this.authMode === "auto" && !this.hasCredentials;
427
+ this.rssClient = new RssClient(this.userAgent);
312
428
  }
313
429
  determineBaseUrl() {
314
430
  switch (this.authMode) {
@@ -338,7 +454,6 @@ var RedditClient = class {
338
454
  ...headers,
339
455
  Authorization: await this.reauthorize()
340
456
  }, 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.");
342
457
  if (cacheable && response.ok) {
343
458
  const text = await response.text();
344
459
  this.cache.set(url, text, response.status);
@@ -394,10 +509,8 @@ var RedditClient = class {
394
509
  return true;
395
510
  }
396
511
  validateWriteAccess() {
397
- if (this.username === void 0 || this.password === void 0) {
398
- 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.");
399
- throw new NotAuthenticatedError("Write operations require REDDIT_USERNAME and REDDIT_PASSWORD");
400
- }
512
+ if (this.usesRss) throw new NotAuthenticatedError("Write operations require OAuth credentials (REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET) in addition to REDDIT_USERNAME and REDDIT_PASSWORD.");
513
+ if (this.username === void 0 || this.password === void 0) throw new NotAuthenticatedError("Write operations require REDDIT_USERNAME and REDDIT_PASSWORD");
401
514
  }
402
515
  async enforceWriteRateLimit() {
403
516
  if (!this.safeMode.enabled || this.safeMode.writeDelayMs <= 0) return;
@@ -463,7 +576,11 @@ var RedditClient = class {
463
576
  if (!this.botDisclosure.enabled || this.botDisclosure.footer === "") return content;
464
577
  return `${content}${this.botDisclosure.footer}`;
465
578
  }
579
+ requiresOAuthError(tool) {
580
+ return Left(new NotAuthenticatedError(`${tool} requires OAuth credentials (REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET). RSS fallback only supports browse_subreddit and get_top_posts.`));
581
+ }
466
582
  async getUser(username) {
583
+ if (this.usesRss) return this.requiresOAuthError("get_user_info");
467
584
  const context = `Failed to get user info for ${username}`;
468
585
  return (await Try.async(async () => {
469
586
  const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/about.json`)).orThrow();
@@ -512,6 +629,7 @@ var RedditClient = class {
512
629
  })).toEither((error) => classifyRedditError(error, context));
513
630
  }
514
631
  async getMyOverview(options = {}) {
632
+ if (this.usesRss) return this.requiresOAuthError("get_my_overview");
515
633
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your overview requires REDDIT_USERNAME"));
516
634
  const { limit = 25, after } = options;
517
635
  const params = new URLSearchParams({ limit: limit.toString() });
@@ -519,6 +637,7 @@ var RedditClient = class {
519
637
  return this.getUserContent(`/user/${encodeURIComponent(this.username)}/overview.json?${params}`, "Failed to get your overview");
520
638
  }
521
639
  async getMySaved(options = {}) {
640
+ if (this.usesRss) return this.requiresOAuthError("get_my_saved");
522
641
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching saved content requires REDDIT_USERNAME"));
523
642
  const { limit = 25, after } = options;
524
643
  const params = new URLSearchParams({ limit: limit.toString() });
@@ -526,6 +645,7 @@ var RedditClient = class {
526
645
  return this.getUserContent(`/user/${encodeURIComponent(this.username)}/saved.json?${params}`, "Failed to get saved content");
527
646
  }
528
647
  async getMe() {
648
+ if (this.usesRss) return this.requiresOAuthError("get_me");
529
649
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your account requires REDDIT_USERNAME"));
530
650
  const context = "Failed to get authenticated user info";
531
651
  return (await Try.async(async () => {
@@ -547,6 +667,7 @@ var RedditClient = class {
547
667
  })).toEither((error) => classifyRedditError(error, context));
548
668
  }
549
669
  async getSubredditInfo(subredditName) {
670
+ if (this.usesRss) return this.requiresOAuthError("get_subreddit_info");
550
671
  const context = `Failed to get subreddit info for ${subredditName}`;
551
672
  return (await Try.async(async () => {
552
673
  const response = (await this.makeRequest(`/r/${normalizeSubreddit(subredditName)}/about.json`)).orThrow();
@@ -567,6 +688,7 @@ var RedditClient = class {
567
688
  })).toEither((error) => classifyRedditError(error, context));
568
689
  }
569
690
  async getSubredditRules(subreddit) {
691
+ if (this.usesRss) return this.requiresOAuthError("get_subreddit_rules");
570
692
  const context = `Failed to get rules for r/${subreddit}`;
571
693
  return (await Try.async(async () => {
572
694
  const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/about/rules.json`)).orThrow();
@@ -582,6 +704,7 @@ var RedditClient = class {
582
704
  })).toEither((error) => classifyRedditError(error, context));
583
705
  }
584
706
  async getPostFlairs(subreddit) {
707
+ if (this.usesRss) return this.requiresOAuthError("get_post_flairs");
585
708
  const context = `Failed to get post flairs for r/${subreddit}`;
586
709
  return (await Try.async(async () => {
587
710
  const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/api/link_flair_v2.json`)).orThrow();
@@ -595,6 +718,7 @@ var RedditClient = class {
595
718
  })).toEither((error) => classifyRedditError(error, context));
596
719
  }
597
720
  async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
721
+ if (this.usesRss) return this.rssClient.fetchSubredditPosts(subreddit, "top", timeFilter, limit, after);
598
722
  const params = new URLSearchParams({
599
723
  t: timeFilter,
600
724
  limit: limit.toString()
@@ -622,6 +746,7 @@ var RedditClient = class {
622
746
  "controversial"
623
747
  ];
624
748
  if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
749
+ if (this.usesRss) return this.rssClient.fetchSubredditPosts(subreddit, sort, timeFilter, limit, after);
625
750
  const params = new URLSearchParams({ limit: limit.toString() });
626
751
  if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
627
752
  if (after !== void 0) params.set("after", after);
@@ -640,6 +765,7 @@ var RedditClient = class {
640
765
  })).toEither((error) => classifyRedditError(error, context));
641
766
  }
642
767
  async getPost(postId, subreddit) {
768
+ if (this.usesRss) return this.requiresOAuthError("get_reddit_post");
643
769
  const context = `Failed to get post with ID ${postId}`;
644
770
  return (await Try.async(async () => {
645
771
  const id = normalizeThingId(postId);
@@ -653,6 +779,7 @@ var RedditClient = class {
653
779
  })).toEither((error) => classifyRedditError(error, context));
654
780
  }
655
781
  async getTrendingSubreddits(limit = 5) {
782
+ if (this.usesRss) return this.requiresOAuthError("get_trending_subreddits");
656
783
  const params = new URLSearchParams({ limit: limit.toString() });
657
784
  const context = `Failed to get trending subreddits`;
658
785
  return (await Try.async(async () => {
@@ -799,7 +926,30 @@ var RedditClient = class {
799
926
  async editComment(thingId, newText) {
800
927
  return this.editThing(thingId, newText, "t1");
801
928
  }
929
+ async setSaved(thingId, saved, category) {
930
+ return (await Try.async(async () => {
931
+ this.validateWriteAccess();
932
+ const fullThingId = normalizeFullname(thingId, "t3");
933
+ const params = new URLSearchParams();
934
+ params.append("id", fullThingId);
935
+ if (saved && category !== void 0) params.append("category", category);
936
+ const response = (await this.makeRequest(saved ? "/api/save" : "/api/unsave", {
937
+ method: "POST",
938
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
939
+ body: params.toString()
940
+ })).orThrow();
941
+ if (!response.ok) throw new HttpError(response.status, `Failed to ${saved ? "save" : "unsave"}: HTTP ${response.status}`);
942
+ return true;
943
+ })).toEither((error) => classifyRedditError(error));
944
+ }
945
+ async saveContent(thingId, category) {
946
+ return this.setSaved(thingId, true, category);
947
+ }
948
+ async unsaveContent(thingId) {
949
+ return this.setSaved(thingId, false);
950
+ }
802
951
  async searchReddit(query, options = {}) {
952
+ if (this.usesRss) return this.requiresOAuthError("search_reddit");
803
953
  const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link", after, before } = options;
804
954
  const params = new URLSearchParams({
805
955
  q: query,
@@ -818,12 +968,18 @@ var RedditClient = class {
818
968
  if (!response.ok) throw new HttpError(response.status, `Failed to search Reddit: HTTP ${response.status}`);
819
969
  const json = await response.json();
820
970
  return {
821
- items: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
971
+ items: json.data.children.flatMap((child) => {
972
+ if (child.kind === "t3") return [parsePostData(child.data)];
973
+ if (child.kind === "t5") return [parseSubredditSearchData(child.data)];
974
+ if (child.kind === "t2") return [parseUserSearchData(child.data)];
975
+ return [];
976
+ }),
822
977
  ...listingCursor(json.data)
823
978
  };
824
979
  })).toEither((error) => classifyRedditError(error, context));
825
980
  }
826
981
  async getPostComments(postId, subreddit, options = {}) {
982
+ if (this.usesRss) return this.requiresOAuthError("get_post_comments");
827
983
  const { sort = "best", limit = 100 } = options;
828
984
  const params = new URLSearchParams({
829
985
  sort,
@@ -863,6 +1019,7 @@ var RedditClient = class {
863
1019
  })).toEither((error) => classifyRedditError(error, context));
864
1020
  }
865
1021
  async getMoreComments(linkId, commentIds) {
1022
+ if (this.usesRss) return this.requiresOAuthError("get_more_comments");
866
1023
  const context = `Failed to expand comments for ${linkId}`;
867
1024
  return (await Try.async(async () => {
868
1025
  var _json$json$data4;
@@ -893,6 +1050,7 @@ var RedditClient = class {
893
1050
  })).toEither((error) => classifyRedditError(error, context));
894
1051
  }
895
1052
  async getUserPosts(username, options = {}) {
1053
+ if (this.usesRss) return this.requiresOAuthError("get_user_posts");
896
1054
  const { sort = "new", timeFilter = "all", limit = 25, after } = options;
897
1055
  const params = new URLSearchParams({
898
1056
  sort,
@@ -912,6 +1070,7 @@ var RedditClient = class {
912
1070
  })).toEither((error) => classifyRedditError(error, context));
913
1071
  }
914
1072
  async getUserComments(username, options = {}) {
1073
+ if (this.usesRss) return this.requiresOAuthError("get_user_comments");
915
1074
  const { sort = "new", timeFilter = "all", limit = 25, after } = options;
916
1075
  const params = new URLSearchParams({
917
1076
  sort,
@@ -1085,7 +1244,7 @@ function formatSubredditInfo(subreddit) {
1085
1244
  //#endregion
1086
1245
  //#region src/index.ts
1087
1246
  dotenv.config({ quiet: true });
1088
- const VERSION = "1.5.3";
1247
+ const VERSION = "1.6.1";
1089
1248
  function validateUserAgent(userAgent, username) {
1090
1249
  if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
1091
1250
  console.error("[Warning] User-Agent does not follow Reddit's recommended format");
@@ -1136,6 +1295,19 @@ function buildSafeModeConfig(safeMode) {
1136
1295
  function unwrapClient() {
1137
1296
  return getRedditClient().orThrow(/* @__PURE__ */ new Error("Reddit client not initialized"));
1138
1297
  }
1298
+ function rssDisclaimer(source) {
1299
+ return source === "rss" ? "\n\n> **RSS mode** — scores, comment counts, and upvote ratios are unavailable. Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET for full data." : "";
1300
+ }
1301
+ function formatPostSummary(post, index, source) {
1302
+ const statsLines = source === "rss" ? [] : [`- Score: ${post.stats.score.toLocaleString()} (${(post.stats.upvoteRatio * 100).toFixed(1)}% upvoted)`, `- Comments: ${post.stats.comments.toLocaleString()}`];
1303
+ return [
1304
+ `### ${index + 1}. ${post.title}`,
1305
+ `- Author: u/${post.author}`,
1306
+ ...statsLines,
1307
+ `- Posted: ${post.metadata.posted}`,
1308
+ `- Link: ${post.links.shortLink}`
1309
+ ].join("\n");
1310
+ }
1139
1311
  function nextPageHint(after) {
1140
1312
  return Option(after).fold(() => "", (cursor) => `\n\n---\nMore results available — call again with after="${cursor}" for the next page.`);
1141
1313
  }
@@ -1208,9 +1380,12 @@ async function setupRedditClient() {
1208
1380
  });
1209
1381
  console.error("[Setup] Reddit client initialized");
1210
1382
  console.error(`[Setup] Authentication mode: ${authMode}`);
1383
+ if (authMode === "anonymous") console.error("[Warning] REDDIT_AUTH_MODE=anonymous is deprecated; it is now an alias for auto without credentials.");
1211
1384
  if (authMode === "anonymous" || !hasCredentials) {
1212
- console.error("[Setup] Using anonymous Reddit API (~10 req/min)");
1213
- console.error("[Setup] No authentication required - ready to use!");
1385
+ console.error("[Setup] RSS fallback mode — no OAuth credentials detected.");
1386
+ console.error("[Setup] Only browse_subreddit and get_top_posts are available (~1 req/min, no engagement metrics).");
1387
+ console.error("[Setup] Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET for full API access (100 req/min).");
1388
+ console.error("[Setup] See: https://www.reddit.com/wiki/api");
1214
1389
  } else {
1215
1390
  console.error("[Setup] Testing Reddit API connection...");
1216
1391
  if (!await client.checkAuthentication()) {
@@ -1252,6 +1427,7 @@ Available capabilities:
1252
1427
  - Search Reddit content across posts and subreddits
1253
1428
  - Create posts and reply to posts/comments (with authentication)
1254
1429
  - Edit your own posts and comments (with authentication)
1430
+ - Save or unsave posts and comments (with authentication)
1255
1431
  - Delete your own posts and comments (with authentication)
1256
1432
  - Analyze engagement metrics and community insights
1257
1433
 
@@ -1308,7 +1484,7 @@ Ready to handle Reddit API requests!`);
1308
1484
  });
1309
1485
  server.addTool({
1310
1486
  name: "get_user_info",
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.",
1487
+ 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; requires OAuth credentials. 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
1488
  annotations: {
1313
1489
  title: "Get User Info",
1314
1490
  readOnlyHint: true,
@@ -1342,7 +1518,7 @@ server.addTool({
1342
1518
  });
1343
1519
  server.addTool({
1344
1520
  name: "get_me",
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.",
1521
+ 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 without user credentials. 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
1522
  annotations: {
1347
1523
  title: "Get My Account",
1348
1524
  readOnlyHint: true,
@@ -1412,7 +1588,7 @@ server.addTool({
1412
1588
  });
1413
1589
  server.addTool({
1414
1590
  name: "get_user_posts",
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.",
1591
+ description: "Get posts submitted by a specific user, with sort (new/hot/top) and time filter. Read-only; requires OAuth credentials. 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
1592
  annotations: {
1417
1593
  title: "Get User Posts",
1418
1594
  readOnlyHint: true,
@@ -1464,7 +1640,7 @@ ${postSummaries}${nextPageHint(page.after)}`;
1464
1640
  });
1465
1641
  server.addTool({
1466
1642
  name: "get_user_comments",
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.",
1643
+ description: "Get comments made by a specific user, with sort (new/hot/top) and time filter. Read-only; requires OAuth credentials. 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
1644
  annotations: {
1469
1645
  title: "Get User Comments",
1470
1646
  readOnlyHint: true,
@@ -1519,7 +1695,7 @@ ${commentSummaries}${nextPageHint(page.after)}`;
1519
1695
  });
1520
1696
  server.addTool({
1521
1697
  name: "get_reddit_post",
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).",
1698
+ 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; requires OAuth credentials. 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
1699
  annotations: {
1524
1700
  title: "Get Reddit Post",
1525
1701
  readOnlyHint: true,
@@ -1568,7 +1744,7 @@ ${formattedPost.bestTimeToEngage}`;
1568
1744
  });
1569
1745
  server.addTool({
1570
1746
  name: "get_top_posts",
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.",
1747
+ 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 without credentials via RSS fallback (titles and links only, no scores or comment counts). Returns a page of posts 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
1748
  annotations: {
1573
1749
  title: "Get Top Posts",
1574
1750
  readOnlyHint: true,
@@ -1593,21 +1769,16 @@ server.addTool({
1593
1769
  }, (page) => {
1594
1770
  const posts = page.items;
1595
1771
  if (posts.length === 0) return `No posts found in ${Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`)} for the specified time period.`;
1596
- const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
1597
- - Author: u/${post.author}
1598
- - Score: ${post.stats.score.toLocaleString()} (${(post.stats.upvoteRatio * 100).toFixed(1)}% upvoted)
1599
- - Comments: ${post.stats.comments.toLocaleString()}
1600
- - Posted: ${post.metadata.posted}
1601
- - Link: ${post.links.shortLink}`).join("\n\n");
1772
+ const postSummaries = posts.map(formatPostInfo).map((post, index) => formatPostSummary(post, index, page.source)).join("\n\n");
1602
1773
  return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
1603
1774
 
1604
- ${postSummaries}${nextPageHint(page.after)}`;
1775
+ ${postSummaries}${nextPageHint(page.after)}${rssDisclaimer(page.source)}`;
1605
1776
  });
1606
1777
  }
1607
1778
  });
1608
1779
  server.addTool({
1609
1780
  name: "browse_subreddit",
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.",
1781
+ 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 without credentials via RSS fallback (titles and links only, no scores or comment counts). `time_filter` applies only to the top and controversial sorts. Returns a page of posts 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
1782
  annotations: {
1612
1783
  title: "Browse Subreddit",
1613
1784
  readOnlyHint: true,
@@ -1640,23 +1811,18 @@ server.addTool({
1640
1811
  const posts = page.items;
1641
1812
  const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
1642
1813
  if (posts.length === 0) return `No posts found in ${location}.`;
1643
- const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
1644
- - Author: u/${post.author}
1645
- - Score: ${post.stats.score.toLocaleString()} (${(post.stats.upvoteRatio * 100).toFixed(1)}% upvoted)
1646
- - Comments: ${post.stats.comments.toLocaleString()}
1647
- - Posted: ${post.metadata.posted}
1648
- - Link: ${post.links.shortLink}`).join("\n\n");
1814
+ const postSummaries = posts.map(formatPostInfo).map((post, index) => formatPostSummary(post, index, page.source)).join("\n\n");
1649
1815
  const timeSuffix = args.sort === "top" || args.sort === "controversial" ? `, ${args.time_filter}` : "";
1650
1816
  const heading = location === "home feed" ? "Home Feed" : location;
1651
1817
  return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
1652
1818
 
1653
- ${postSummaries}${nextPageHint(page.after)}`;
1819
+ ${postSummaries}${nextPageHint(page.after)}${rssDisclaimer(page.source)}`;
1654
1820
  });
1655
1821
  }
1656
1822
  });
1657
1823
  server.addTool({
1658
1824
  name: "get_subreddit_info",
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'.",
1825
+ 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; requires OAuth credentials. 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
1826
  annotations: {
1661
1827
  title: "Get Subreddit Info",
1662
1828
  readOnlyHint: true,
@@ -1700,7 +1866,7 @@ ${formattedSubreddit.description.full}
1700
1866
  });
1701
1867
  server.addTool({
1702
1868
  name: "get_subreddit_rules",
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.",
1869
+ description: "Get a subreddit's posting rules (each rule's name, what it applies to, and its description). Read-only; requires OAuth credentials. 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
1870
  annotations: {
1705
1871
  title: "Get Subreddit Rules",
1706
1872
  readOnlyHint: true,
@@ -1725,7 +1891,7 @@ ${ruleList}`;
1725
1891
  });
1726
1892
  server.addTool({
1727
1893
  name: "get_post_flairs",
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.",
1894
+ 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 without credentials. 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
1895
  annotations: {
1730
1896
  title: "Get Post Flairs",
1731
1897
  readOnlyHint: true,
@@ -1751,7 +1917,7 @@ Pass the desired \`flair_id\` to \`create_post\`.`;
1751
1917
  });
1752
1918
  server.addTool({
1753
1919
  name: "get_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'.",
1920
+ description: "Get the subreddits Reddit is currently featuring as trending/popular. Read-only, no parameters; requires OAuth credentials. 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
1921
  annotations: {
1756
1922
  title: "Get Trending Subreddits",
1757
1923
  readOnlyHint: true,
@@ -1768,7 +1934,7 @@ ${trendingSubreddits.map((subreddit, index) => `${index + 1}. r/${subreddit}`).j
1768
1934
  });
1769
1935
  server.addTool({
1770
1936
  name: "search_reddit",
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.",
1937
+ description: "Search Reddit for posts — or subreddits/users via `type` — optionally scoped to one subreddit, with sort and time filters. Read-only; requires OAuth credentials. 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
1938
  annotations: {
1773
1939
  title: "Search Reddit",
1774
1940
  readOnlyHint: true,
@@ -2005,9 +2171,54 @@ The comment ${args.thing_id} has been updated with your new content.
2005
2171
  **Note**: An "edited" marker will appear on your comment to show it has been modified.`);
2006
2172
  }
2007
2173
  });
2174
+ server.addTool({
2175
+ name: "save_content",
2176
+ description: "Save a post or comment to your account (a private bookmark, listed by get_my_saved). Mutating but idempotent — saving an already-saved item is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD; works on any post/comment you can see, not just your own. Accepts a full thing id (t3_ for a post, t1_ for a comment) — a bare id is treated as a post. Use unsave_content to undo.",
2177
+ annotations: {
2178
+ title: "Save Post or Comment",
2179
+ readOnlyHint: false,
2180
+ destructiveHint: false,
2181
+ idempotentHint: true,
2182
+ openWorldHint: true
2183
+ },
2184
+ parameters: z.object({
2185
+ thing_id: z.string().describe("The post or comment to save: a full thing id 't3_<id>' (post) or 't1_<id>' (comment). A bare id is treated as a post."),
2186
+ category: z.string().optional().describe("Optional save category to file this under (Reddit Premium feature; ignored on other accounts).")
2187
+ }),
2188
+ execute: async (args) => {
2189
+ const client = unwrapClient();
2190
+ 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.");
2191
+ return (await client.saveContent(args.thing_id, args.category)).fold((err) => {
2192
+ throw new Error(`Failed to save content: ${err.message}`);
2193
+ }, () => `# Content Saved Successfully
2194
+
2195
+ ${args.thing_id} has been saved to your account. Use get_my_saved to view your saved items.`);
2196
+ }
2197
+ });
2198
+ server.addTool({
2199
+ name: "unsave_content",
2200
+ description: "Remove a post or comment from your saved items (undoes save_content). Mutating but idempotent — unsaving an item that isn't saved is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD. Accepts a full thing id (t3_ for a post, t1_ for a comment) — a bare id is treated as a post.",
2201
+ annotations: {
2202
+ title: "Unsave Post or Comment",
2203
+ readOnlyHint: false,
2204
+ destructiveHint: false,
2205
+ idempotentHint: true,
2206
+ openWorldHint: true
2207
+ },
2208
+ parameters: z.object({ thing_id: z.string().describe("The post or comment to unsave: a full thing id 't3_<id>' (post) or 't1_<id>' (comment). A bare id is treated as a post.") }),
2209
+ execute: async (args) => {
2210
+ const client = unwrapClient();
2211
+ 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.");
2212
+ return (await client.unsaveContent(args.thing_id)).fold((err) => {
2213
+ throw new Error(`Failed to unsave content: ${err.message}`);
2214
+ }, () => `# Content Unsaved Successfully
2215
+
2216
+ ${args.thing_id} has been removed from your saved items.`);
2217
+ }
2218
+ });
2008
2219
  server.addTool({
2009
2220
  name: "get_post_comments",
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.",
2221
+ description: "Get the comment thread for a post (by post id + subreddit), sorted best/top/new/controversial/old/qa. Read-only; requires OAuth credentials. 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
2222
  annotations: {
2012
2223
  title: "Get Post Comments",
2013
2224
  readOnlyHint: true,
@@ -2060,7 +2271,7 @@ ${comment.body}
2060
2271
  });
2061
2272
  server.addTool({
2062
2273
  name: "get_more_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.",
2274
+ description: "Expand truncated 'load more comments' stubs in a thread. Read-only; requires OAuth credentials. 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
2275
  annotations: {
2065
2276
  title: "Get More Comments",
2066
2277
  readOnlyHint: true,