reddit-mcp-server 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,6 +3,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,
@@ -277,6 +353,8 @@ var RedditClient = class {
277
353
  botDisclosure;
278
354
  cache;
279
355
  retry;
356
+ rssClient;
357
+ usesRss;
280
358
  accessToken;
281
359
  tokenExpiry = 0;
282
360
  authenticated = false;
@@ -309,6 +387,8 @@ var RedditClient = class {
309
387
  baseDelayMs: 1e3,
310
388
  maxDelayMs: 6e4
311
389
  };
390
+ this.usesRss = this.authMode === "anonymous" || this.authMode === "auto" && !this.hasCredentials;
391
+ this.rssClient = new RssClient(this.userAgent);
312
392
  }
313
393
  determineBaseUrl() {
314
394
  switch (this.authMode) {
@@ -338,7 +418,6 @@ var RedditClient = class {
338
418
  ...headers,
339
419
  Authorization: await this.reauthorize()
340
420
  }, 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
421
  if (cacheable && response.ok) {
343
422
  const text = await response.text();
344
423
  this.cache.set(url, text, response.status);
@@ -394,10 +473,8 @@ var RedditClient = class {
394
473
  return true;
395
474
  }
396
475
  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
- }
476
+ 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.");
477
+ if (this.username === void 0 || this.password === void 0) throw new NotAuthenticatedError("Write operations require REDDIT_USERNAME and REDDIT_PASSWORD");
401
478
  }
402
479
  async enforceWriteRateLimit() {
403
480
  if (!this.safeMode.enabled || this.safeMode.writeDelayMs <= 0) return;
@@ -463,7 +540,11 @@ var RedditClient = class {
463
540
  if (!this.botDisclosure.enabled || this.botDisclosure.footer === "") return content;
464
541
  return `${content}${this.botDisclosure.footer}`;
465
542
  }
543
+ requiresOAuthError(tool) {
544
+ 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.`));
545
+ }
466
546
  async getUser(username) {
547
+ if (this.usesRss) return this.requiresOAuthError("get_user_info");
467
548
  const context = `Failed to get user info for ${username}`;
468
549
  return (await Try.async(async () => {
469
550
  const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/about.json`)).orThrow();
@@ -512,6 +593,7 @@ var RedditClient = class {
512
593
  })).toEither((error) => classifyRedditError(error, context));
513
594
  }
514
595
  async getMyOverview(options = {}) {
596
+ if (this.usesRss) return this.requiresOAuthError("get_my_overview");
515
597
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your overview requires REDDIT_USERNAME"));
516
598
  const { limit = 25, after } = options;
517
599
  const params = new URLSearchParams({ limit: limit.toString() });
@@ -519,6 +601,7 @@ var RedditClient = class {
519
601
  return this.getUserContent(`/user/${encodeURIComponent(this.username)}/overview.json?${params}`, "Failed to get your overview");
520
602
  }
521
603
  async getMySaved(options = {}) {
604
+ if (this.usesRss) return this.requiresOAuthError("get_my_saved");
522
605
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching saved content requires REDDIT_USERNAME"));
523
606
  const { limit = 25, after } = options;
524
607
  const params = new URLSearchParams({ limit: limit.toString() });
@@ -526,6 +609,7 @@ var RedditClient = class {
526
609
  return this.getUserContent(`/user/${encodeURIComponent(this.username)}/saved.json?${params}`, "Failed to get saved content");
527
610
  }
528
611
  async getMe() {
612
+ if (this.usesRss) return this.requiresOAuthError("get_me");
529
613
  if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your account requires REDDIT_USERNAME"));
530
614
  const context = "Failed to get authenticated user info";
531
615
  return (await Try.async(async () => {
@@ -547,6 +631,7 @@ var RedditClient = class {
547
631
  })).toEither((error) => classifyRedditError(error, context));
548
632
  }
549
633
  async getSubredditInfo(subredditName) {
634
+ if (this.usesRss) return this.requiresOAuthError("get_subreddit_info");
550
635
  const context = `Failed to get subreddit info for ${subredditName}`;
551
636
  return (await Try.async(async () => {
552
637
  const response = (await this.makeRequest(`/r/${normalizeSubreddit(subredditName)}/about.json`)).orThrow();
@@ -567,6 +652,7 @@ var RedditClient = class {
567
652
  })).toEither((error) => classifyRedditError(error, context));
568
653
  }
569
654
  async getSubredditRules(subreddit) {
655
+ if (this.usesRss) return this.requiresOAuthError("get_subreddit_rules");
570
656
  const context = `Failed to get rules for r/${subreddit}`;
571
657
  return (await Try.async(async () => {
572
658
  const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/about/rules.json`)).orThrow();
@@ -582,6 +668,7 @@ var RedditClient = class {
582
668
  })).toEither((error) => classifyRedditError(error, context));
583
669
  }
584
670
  async getPostFlairs(subreddit) {
671
+ if (this.usesRss) return this.requiresOAuthError("get_post_flairs");
585
672
  const context = `Failed to get post flairs for r/${subreddit}`;
586
673
  return (await Try.async(async () => {
587
674
  const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/api/link_flair_v2.json`)).orThrow();
@@ -595,6 +682,7 @@ var RedditClient = class {
595
682
  })).toEither((error) => classifyRedditError(error, context));
596
683
  }
597
684
  async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
685
+ if (this.usesRss) return this.rssClient.fetchSubredditPosts(subreddit, "top", timeFilter, limit, after);
598
686
  const params = new URLSearchParams({
599
687
  t: timeFilter,
600
688
  limit: limit.toString()
@@ -622,6 +710,7 @@ var RedditClient = class {
622
710
  "controversial"
623
711
  ];
624
712
  if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
713
+ if (this.usesRss) return this.rssClient.fetchSubredditPosts(subreddit, sort, timeFilter, limit, after);
625
714
  const params = new URLSearchParams({ limit: limit.toString() });
626
715
  if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
627
716
  if (after !== void 0) params.set("after", after);
@@ -640,6 +729,7 @@ var RedditClient = class {
640
729
  })).toEither((error) => classifyRedditError(error, context));
641
730
  }
642
731
  async getPost(postId, subreddit) {
732
+ if (this.usesRss) return this.requiresOAuthError("get_reddit_post");
643
733
  const context = `Failed to get post with ID ${postId}`;
644
734
  return (await Try.async(async () => {
645
735
  const id = normalizeThingId(postId);
@@ -653,6 +743,7 @@ var RedditClient = class {
653
743
  })).toEither((error) => classifyRedditError(error, context));
654
744
  }
655
745
  async getTrendingSubreddits(limit = 5) {
746
+ if (this.usesRss) return this.requiresOAuthError("get_trending_subreddits");
656
747
  const params = new URLSearchParams({ limit: limit.toString() });
657
748
  const context = `Failed to get trending subreddits`;
658
749
  return (await Try.async(async () => {
@@ -800,6 +891,7 @@ var RedditClient = class {
800
891
  return this.editThing(thingId, newText, "t1");
801
892
  }
802
893
  async searchReddit(query, options = {}) {
894
+ if (this.usesRss) return this.requiresOAuthError("search_reddit");
803
895
  const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link", after, before } = options;
804
896
  const params = new URLSearchParams({
805
897
  q: query,
@@ -824,6 +916,7 @@ var RedditClient = class {
824
916
  })).toEither((error) => classifyRedditError(error, context));
825
917
  }
826
918
  async getPostComments(postId, subreddit, options = {}) {
919
+ if (this.usesRss) return this.requiresOAuthError("get_post_comments");
827
920
  const { sort = "best", limit = 100 } = options;
828
921
  const params = new URLSearchParams({
829
922
  sort,
@@ -863,6 +956,7 @@ var RedditClient = class {
863
956
  })).toEither((error) => classifyRedditError(error, context));
864
957
  }
865
958
  async getMoreComments(linkId, commentIds) {
959
+ if (this.usesRss) return this.requiresOAuthError("get_more_comments");
866
960
  const context = `Failed to expand comments for ${linkId}`;
867
961
  return (await Try.async(async () => {
868
962
  var _json$json$data4;
@@ -893,6 +987,7 @@ var RedditClient = class {
893
987
  })).toEither((error) => classifyRedditError(error, context));
894
988
  }
895
989
  async getUserPosts(username, options = {}) {
990
+ if (this.usesRss) return this.requiresOAuthError("get_user_posts");
896
991
  const { sort = "new", timeFilter = "all", limit = 25, after } = options;
897
992
  const params = new URLSearchParams({
898
993
  sort,
@@ -912,6 +1007,7 @@ var RedditClient = class {
912
1007
  })).toEither((error) => classifyRedditError(error, context));
913
1008
  }
914
1009
  async getUserComments(username, options = {}) {
1010
+ if (this.usesRss) return this.requiresOAuthError("get_user_comments");
915
1011
  const { sort = "new", timeFilter = "all", limit = 25, after } = options;
916
1012
  const params = new URLSearchParams({
917
1013
  sort,
@@ -1085,7 +1181,7 @@ function formatSubredditInfo(subreddit) {
1085
1181
  //#endregion
1086
1182
  //#region src/index.ts
1087
1183
  dotenv.config({ quiet: true });
1088
- const VERSION = "1.5.2";
1184
+ const VERSION = "1.6.0";
1089
1185
  function validateUserAgent(userAgent, username) {
1090
1186
  if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
1091
1187
  console.error("[Warning] User-Agent does not follow Reddit's recommended format");
@@ -1136,6 +1232,19 @@ function buildSafeModeConfig(safeMode) {
1136
1232
  function unwrapClient() {
1137
1233
  return getRedditClient().orThrow(/* @__PURE__ */ new Error("Reddit client not initialized"));
1138
1234
  }
1235
+ function rssDisclaimer(source) {
1236
+ 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." : "";
1237
+ }
1238
+ function formatPostSummary(post, index, source) {
1239
+ const statsLines = source === "rss" ? [] : [`- Score: ${post.stats.score.toLocaleString()} (${(post.stats.upvoteRatio * 100).toFixed(1)}% upvoted)`, `- Comments: ${post.stats.comments.toLocaleString()}`];
1240
+ return [
1241
+ `### ${index + 1}. ${post.title}`,
1242
+ `- Author: u/${post.author}`,
1243
+ ...statsLines,
1244
+ `- Posted: ${post.metadata.posted}`,
1245
+ `- Link: ${post.links.shortLink}`
1246
+ ].join("\n");
1247
+ }
1139
1248
  function nextPageHint(after) {
1140
1249
  return Option(after).fold(() => "", (cursor) => `\n\n---\nMore results available — call again with after="${cursor}" for the next page.`);
1141
1250
  }
@@ -1208,9 +1317,12 @@ async function setupRedditClient() {
1208
1317
  });
1209
1318
  console.error("[Setup] Reddit client initialized");
1210
1319
  console.error(`[Setup] Authentication mode: ${authMode}`);
1320
+ if (authMode === "anonymous") console.error("[Warning] REDDIT_AUTH_MODE=anonymous is deprecated; it is now an alias for auto without credentials.");
1211
1321
  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!");
1322
+ console.error("[Setup] RSS fallback mode no OAuth credentials detected.");
1323
+ console.error("[Setup] Only browse_subreddit and get_top_posts are available (~1 req/min, no engagement metrics).");
1324
+ console.error("[Setup] Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET for full API access (100 req/min).");
1325
+ console.error("[Setup] See: https://www.reddit.com/wiki/api");
1214
1326
  } else {
1215
1327
  console.error("[Setup] Testing Reddit API connection...");
1216
1328
  if (!await client.checkAuthentication()) {
@@ -1308,7 +1420,7 @@ Ready to handle Reddit API requests!`);
1308
1420
  });
1309
1421
  server.addTool({
1310
1422
  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.",
1423
+ 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
1424
  annotations: {
1313
1425
  title: "Get User Info",
1314
1426
  readOnlyHint: true,
@@ -1342,7 +1454,7 @@ server.addTool({
1342
1454
  });
1343
1455
  server.addTool({
1344
1456
  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.",
1457
+ 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
1458
  annotations: {
1347
1459
  title: "Get My Account",
1348
1460
  readOnlyHint: true,
@@ -1412,7 +1524,7 @@ server.addTool({
1412
1524
  });
1413
1525
  server.addTool({
1414
1526
  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.",
1527
+ 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
1528
  annotations: {
1417
1529
  title: "Get User Posts",
1418
1530
  readOnlyHint: true,
@@ -1464,7 +1576,7 @@ ${postSummaries}${nextPageHint(page.after)}`;
1464
1576
  });
1465
1577
  server.addTool({
1466
1578
  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.",
1579
+ 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
1580
  annotations: {
1469
1581
  title: "Get User Comments",
1470
1582
  readOnlyHint: true,
@@ -1519,7 +1631,7 @@ ${commentSummaries}${nextPageHint(page.after)}`;
1519
1631
  });
1520
1632
  server.addTool({
1521
1633
  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).",
1634
+ 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
1635
  annotations: {
1524
1636
  title: "Get Reddit Post",
1525
1637
  readOnlyHint: true,
@@ -1568,7 +1680,7 @@ ${formattedPost.bestTimeToEngage}`;
1568
1680
  });
1569
1681
  server.addTool({
1570
1682
  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.",
1683
+ 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
1684
  annotations: {
1573
1685
  title: "Get Top Posts",
1574
1686
  readOnlyHint: true,
@@ -1593,21 +1705,16 @@ server.addTool({
1593
1705
  }, (page) => {
1594
1706
  const posts = page.items;
1595
1707
  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");
1708
+ const postSummaries = posts.map(formatPostInfo).map((post, index) => formatPostSummary(post, index, page.source)).join("\n\n");
1602
1709
  return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
1603
1710
 
1604
- ${postSummaries}${nextPageHint(page.after)}`;
1711
+ ${postSummaries}${nextPageHint(page.after)}${rssDisclaimer(page.source)}`;
1605
1712
  });
1606
1713
  }
1607
1714
  });
1608
1715
  server.addTool({
1609
1716
  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.",
1717
+ 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
1718
  annotations: {
1612
1719
  title: "Browse Subreddit",
1613
1720
  readOnlyHint: true,
@@ -1640,23 +1747,18 @@ server.addTool({
1640
1747
  const posts = page.items;
1641
1748
  const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
1642
1749
  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");
1750
+ const postSummaries = posts.map(formatPostInfo).map((post, index) => formatPostSummary(post, index, page.source)).join("\n\n");
1649
1751
  const timeSuffix = args.sort === "top" || args.sort === "controversial" ? `, ${args.time_filter}` : "";
1650
1752
  const heading = location === "home feed" ? "Home Feed" : location;
1651
1753
  return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
1652
1754
 
1653
- ${postSummaries}${nextPageHint(page.after)}`;
1755
+ ${postSummaries}${nextPageHint(page.after)}${rssDisclaimer(page.source)}`;
1654
1756
  });
1655
1757
  }
1656
1758
  });
1657
1759
  server.addTool({
1658
1760
  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'.",
1761
+ 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
1762
  annotations: {
1661
1763
  title: "Get Subreddit Info",
1662
1764
  readOnlyHint: true,
@@ -1700,7 +1802,7 @@ ${formattedSubreddit.description.full}
1700
1802
  });
1701
1803
  server.addTool({
1702
1804
  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.",
1805
+ 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
1806
  annotations: {
1705
1807
  title: "Get Subreddit Rules",
1706
1808
  readOnlyHint: true,
@@ -1725,7 +1827,7 @@ ${ruleList}`;
1725
1827
  });
1726
1828
  server.addTool({
1727
1829
  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.",
1830
+ 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
1831
  annotations: {
1730
1832
  title: "Get Post Flairs",
1731
1833
  readOnlyHint: true,
@@ -1751,7 +1853,7 @@ Pass the desired \`flair_id\` to \`create_post\`.`;
1751
1853
  });
1752
1854
  server.addTool({
1753
1855
  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'.",
1856
+ 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
1857
  annotations: {
1756
1858
  title: "Get Trending Subreddits",
1757
1859
  readOnlyHint: true,
@@ -1768,7 +1870,7 @@ ${trendingSubreddits.map((subreddit, index) => `${index + 1}. r/${subreddit}`).j
1768
1870
  });
1769
1871
  server.addTool({
1770
1872
  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.",
1873
+ 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
1874
  annotations: {
1773
1875
  title: "Search Reddit",
1774
1876
  readOnlyHint: true,
@@ -2007,7 +2109,7 @@ The comment ${args.thing_id} has been updated with your new content.
2007
2109
  });
2008
2110
  server.addTool({
2009
2111
  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.",
2112
+ 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
2113
  annotations: {
2012
2114
  title: "Get Post Comments",
2013
2115
  readOnlyHint: true,
@@ -2060,7 +2162,7 @@ ${comment.body}
2060
2162
  });
2061
2163
  server.addTool({
2062
2164
  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.",
2165
+ 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
2166
  annotations: {
2065
2167
  title: "Get More Comments",
2066
2168
  readOnlyHint: true,