redditapis-mcp 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 (2026-09-06)
4
+
5
+ ### Fixed
6
+
7
+ - **The npm package page was showing a description cut off mid-word.** `package.json` carried a 394-character description; npm stores at most 255 and truncates the rest without an error or a warning, so the visible end of the description on npmjs.com was the fragment `register/test/dele`. 139 characters were being silently discarded. Measured against `registry.npmjs.org` rather than guessed. The description is now 249 characters and ends as a sentence, and `scripts/prepublish-tenant-check.mjs` refuses to publish one that is over the bound or that does not end in a full stop, the second check being the one that catches a cut string regardless of its length.
8
+
9
+ ## 0.5.0 (2026-09-06)
10
+
11
+ ### Added
12
+
13
+ - **`reddit_home_feed`, the caller's own front page.** A shared account pool can never answer "what is on MY home feed", so this reads it with your own Reddit session, sent as headers rather than in the URL. Part of the same change that gave nine existing read tools an authenticated mode: send `x-reddit-session` and `x-reddit-loid` and a private, restricted or member-only subreddit is readable as the account that belongs there, instead of coming back empty. Send neither and the read is served anonymously from the pool exactly as before, so existing integrations are untouched.
14
+ - **`reddit_verify_comments`, verify up to a hundred comments in one call.**
15
+ - **`reddit_feedback_list`, find a report whose id you lost.** `GET /feedback` with cursor paging and a type filter, so a report you sent and did not record is still reachable.
16
+
17
+ ### Fixed
18
+
19
+ - **A foreign-tenant token that `git push` shipped and `npm pack` did not.** The published tarball was clean, so this never reached a customer through npm, but the string was in the repo.
20
+ - The stuck-lock test used the wrong queue-directory env var and wrote to the real home directory instead of its fixture.
21
+ - `send` now releases the queue lock across the network, a 409 carries no hint, and a posted draft fails soft rather than stranding the queue.
22
+
23
+ ### Why a minor rather than a patch
24
+
25
+ The catalog moved from 38 tools to 41. A client enumerating tools sees three it did not have, which is a capability change, so anyone pinned to 0.4.x opts in rather than receiving it silently.
26
+
27
+ ### Note on the gap this closes
28
+
29
+ 0.4.0 was published on 2026-09-04 and the registry served it unchanged for two days while HEAD moved 50 commits ahead. Because both the registry and the repo said `0.4.0`, every version-based freshness check read clean while the BYTES differed: `src/index.js`, `src/tools.js` and `README.md` all diverged, and customers running `npx redditapis-mcp` were enumerating 38 tools against a repo that shipped 41. Found by `ship-chain.sh`, which compares the published tarball to HEAD file by file rather than comparing version strings.
30
+
3
31
  ## 0.4.0 (2026-09-04)
4
32
 
5
33
  ### Added
package/README.md CHANGED
@@ -125,6 +125,9 @@ A few conventions across the catalog:
125
125
  | Tool | Endpoint | What it does |
126
126
  |---|---|---|
127
127
  | `reddit_subreddit_posts` | `GET /api/reddit/posts` | List a subreddit's posts by `sort` (`new`, `hot`, `top`, `rising`, `controversial`, `best`). |
128
+ | `reddit_feedback_list` | `GET /feedback` | List the reports this account has sent, newest first, with their status. The way back to a report whose id was not kept. Free. |
129
+ | `reddit_verify_comments` | `POST /api/reddit/comments/verify` | Check whether up to 100 specific comments still exist and are publicly visible. A read despite the POST; tells 'deleted by author' from 'removed by a mod' from 'still there'. |
130
+ | `reddit_home_feed` | `GET /api/reddit/feed` | Read YOUR OWN Reddit home feed. Needs your `reddit_session` and `loid` from `POST /api/reddit/login` (a REST call, not an MCP tool); they travel as headers, never in the URL. |
128
131
  | `reddit_subreddit_top` | `GET /api/reddit/sub/{name}/top` | Top posts of a subreddit for a time window (`t`). |
129
132
  | `reddit_subreddit_comments` | `GET /api/reddit/sub/{name}/comments` | Stream the newest comments across an entire subreddit (not one post's thread). |
130
133
  | `reddit_subreddit_about` | `GET /api/reddit/sub/{name}/about` | A subreddit's public metadata: title, description, subscriber and active-user counts, type, NSFW flag. |
@@ -180,6 +183,7 @@ Modelled on Claude Code's own feedback tool. When a call fails in a way that is
180
183
  | Tool | Endpoint | What it does |
181
184
  |------|----------|--------------|
182
185
  | `reddit_feedback_send` | `POST /feedback` | `action: "draft"` (default) queues a report locally and sends nothing; `"list"` shows the queue; `"send"` posts only the drafts you name; `"discard"` drops them. |
186
+ | `reddit_account_me` | `GET /account/me` | How much credit this key has left, before spending any. Free, never metered. |
183
187
  | `reddit_feedback_get` | `GET /feedback/{id}` | Read a sent report's status (`new`, `triaged`, `shipped`, `declined`) and the team's response. |
184
188
 
185
189
  ## Usage examples
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "mcpName": "io.github.redditapis/redditapis-mcp",
5
- "description": "Official MCP server for redditapis.com, the Reddit API (subreddit listings, post + comment + community + user + media search, comment trees, top posts, user profiles) plus monitor/webhook management (create/list/update/remove a monitor, register/test/delete a webhook, read delivery history) and user-reviewed product feedback to the team as native tools for Claude, Cursor, and any MCP client.",
5
+ "description": "Official MCP server for redditapis.com. Search Reddit posts, comments, communities, users and media; read subreddits, comment trees, top posts and profiles; create and manage monitors and webhooks. Native tools for Claude, Cursor and any MCP client.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/redditapis/redditapis-mcp.git"
package/src/index.js CHANGED
@@ -26,7 +26,7 @@
26
26
  import { createRequire } from "node:module";
27
27
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
- import { TOOLS, buildQuery, buildPath, buildBody } from "./tools.js";
29
+ import { TOOLS, buildQuery, buildPath, buildBody, buildHeaders } from "./tools.js";
30
30
  import { createFeedbackHandler, sameFailure } from "./feedback.js";
31
31
 
32
32
  const API_KEY = process.env.REDDITAPIS_KEY || process.env.REDDIT_APIS_KEY;
@@ -56,8 +56,45 @@ let lastError = null;
56
56
  // (see buildBody -- `tool` is passed through only to read its
57
57
  // `filterSpecFields`). Forwards the API key as a Bearer token (redditapis.com
58
58
  // authenticates via `Authorization: Bearer <key>` only).
59
+ // The trailing sentence appended to a failed call, so the model reads an error
60
+ // it can act on rather than a bare status.
61
+ //
62
+ // A 404 HINT HAS TO KNOW WHICH ENDPOINT WAS CALLED. This was a flat status-only
63
+ // ternary, so every 404 in the catalog got the listing-shaped sentence, and a
64
+ // caller whose FEEDBACK id was not found was told to check their subreddit,
65
+ // post id or permalink. None of those are involved. The feedback endpoints are
66
+ // the first mounted outside the /api/reddit surface, so they are the first
67
+ // place that assumption is plainly wrong; /account/* would have been next.
68
+ //
69
+ // Only the 404 varies, because it is the only status whose useful advice
70
+ // depends on what was being looked up. Everything else is about the KEY, the
71
+ // CREDIT balance or OUR service and reads the same on every endpoint.
72
+ const NOT_FOUND_HINTS = [
73
+ [/^\/feedback/, " (not found. No feedback report with that id on this account, and an id from another account will not resolve here. Use the id returned by reddit_feedback_send action=send.)"],
74
+ [/^\/account/, " (not found. That account resource does not exist for this key.)"],
75
+ ];
76
+ const DEFAULT_NOT_FOUND_HINT =
77
+ " (not found. The subreddit, post id, user, or permalink may be wrong or deleted)";
78
+
79
+ export function hintFor(status, path) {
80
+ if (status === 401) return " (invalid or missing API key, verify REDDITAPIS_KEY at https://www.redditapis.com)";
81
+ if (status === 402) return " (insufficient credits, top up at https://www.redditapis.com)";
82
+ if (status === 403) return " (access forbidden. The subreddit/user may be private, banned, or quarantined)";
83
+ if (status === 404) {
84
+ for (const [re, hint] of NOT_FOUND_HINTS) if (re.test(path || "")) return hint;
85
+ return DEFAULT_NOT_FOUND_HINT;
86
+ }
87
+ if (status === 429) return " (rate limited. Wait a few seconds and retry, or reduce request frequency)";
88
+ if (status >= 500) return " (upstream API error. Retry in a moment; if persistent, contact https://www.redditapis.com)";
89
+ return "";
90
+ }
91
+
59
92
  async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
60
- const { path, rest } = buildPath(pathTemplate, args);
93
+ const { path, rest: pathRest } = buildPath(pathTemplate, args);
94
+ // A tool that declares `sessionHeaders` has its Reddit session args lifted
95
+ // out of the query and onto headers. For every other tool this is the
96
+ // identity, so the request is byte-for-byte what it was before.
97
+ const { headers: sessionHeaders, rest } = buildHeaders(tool, pathRest);
61
98
  const isWrite = method !== "GET";
62
99
  const q = isWrite ? "" : buildQuery(rest);
63
100
  const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
@@ -66,6 +103,7 @@ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
66
103
  Authorization: `Bearer ${API_KEY}`,
67
104
  accept: "application/json",
68
105
  "user-agent": `reddit-mcp/${VERSION}`,
106
+ ...sessionHeaders,
69
107
  };
70
108
  let requestBody;
71
109
  if (isWrite) {
@@ -79,20 +117,7 @@ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
79
117
  const res = await fetch(url, { method, headers, body: requestBody, signal: ctrl.signal });
80
118
  const body = await res.text();
81
119
  if (!res.ok) {
82
- const hint =
83
- res.status === 401
84
- ? " (invalid or missing API key, verify REDDITAPIS_KEY at https://www.redditapis.com)"
85
- : res.status === 402
86
- ? " (insufficient credits, top up at https://www.redditapis.com)"
87
- : res.status === 403
88
- ? " (access forbidden. The subreddit/user may be private, banned, or quarantined)"
89
- : res.status === 404
90
- ? " (not found. The subreddit, post id, user, or permalink may be wrong or deleted)"
91
- : res.status === 429
92
- ? " (rate limited. Wait a few seconds and retry, or reduce request frequency)"
93
- : res.status >= 500
94
- ? " (upstream API error. Retry in a moment; if persistent, contact https://www.redditapis.com)"
95
- : "";
120
+ const hint = hintFor(res.status, path);
96
121
  lastError = {
97
122
  path,
98
123
  method,
package/src/tools.js CHANGED
@@ -112,6 +112,28 @@ const LISTING_COOKIES = {
112
112
  ),
113
113
  };
114
114
 
115
+ // The SESSION-HEADER form of the same thing, for tools that declare
116
+ // `sessionHeaders: true`. Identical fields, different transport: buildHeaders
117
+ // lifts these onto x-reddit-* headers instead of the query string, because a
118
+ // session cookie in a URL ends up in every log along the path. LISTING_COOKIES
119
+ // above stays as it is; the four tools using it ship with the query form and
120
+ // changing what they put on the wire would be a behaviour change nobody asked
121
+ // for. The REST API accepts both.
122
+ const SESSION_HEADERS_REQUIRED = {
123
+ reddit_session: z.string().min(1).describe(
124
+ "Your Reddit `reddit_session` cookie, from POST /api/reddit/login on the REST API (not an MCP tool). Sent as a header, never in the URL. Required.",
125
+ ),
126
+ loid: z.string().min(1).describe(
127
+ "Your Reddit `loid` cookie, from the same login response. Required, and must be sent together with reddit_session.",
128
+ ),
129
+ token_v2: z.string().optional().describe(
130
+ "Your Reddit `token_v2` cookie, from the same login response. Optional.",
131
+ ),
132
+ proxy: z.string().optional().describe(
133
+ "Optional proxy this read egresses through, so the request reaches Reddit from the IP this account normally acts from. http://user:pass@host:port or host:port. Pinned across retries.",
134
+ ),
135
+ };
136
+
115
137
  // ── monitoring filter_spec fragments, shared by monitor_add and monitor_update ──
116
138
  // v1 is SUBREDDIT-SCOPED ONLY (all-Reddit keyword monitoring is not available),
117
139
  // matching apps/api/src/lib/monitor-filter.js's own validation exactly, so a
@@ -211,6 +233,34 @@ export const TOOLS = [
211
233
  ...LIMIT,
212
234
  },
213
235
  },
236
+ {
237
+ name: "reddit_verify_comments",
238
+ path: "/api/reddit/comments/verify",
239
+ method: "POST",
240
+ description:
241
+ "Check whether specific Reddit comments still EXIST and are publicly visible, in one batch of up to 100 ids. A READ despite being a POST (ids travel in the body because a hundred of them do not fit in a URL); it costs the same as any other read and changes nothing on Reddit. Use it to tell 'deleted by the author' from 'removed by a moderator' from 'still there', which a normal comment fetch cannot distinguish, and to re-check a list of comments you posted or collected earlier. Accepts bare ids and t1_-prefixed fullnames interchangeably. Returns one row per id, in the order you sent them, each with a status. Example: ids=['n5abcde','t1_n5fghij'].",
242
+ shape: {
243
+ ids: z.array(z.string().min(1)).min(1).max(100).describe(
244
+ "Comment ids to check, 1 to 100 per call. Bare id ('n5abcde') or fullname ('t1_n5abcde'), mixed freely. Reddit's own batch lookup caps at 100; split larger lists across calls. Required.",
245
+ ),
246
+ },
247
+ },
248
+ {
249
+ name: "reddit_home_feed",
250
+ path: "/api/reddit/feed",
251
+ sessionHeaders: true,
252
+ description:
253
+ "Read YOUR OWN Reddit home feed, the front page your subscriptions produce. Every other read tool here is served from a shared pool of accounts, so it cannot answer 'what is on my feed' -- this one sends your session instead. REQUIRES your own Reddit session: call POST /api/reddit/login on the REST API first (not an MCP tool) and pass the `reddit_session` and `loid` cookies it returns. Without them this returns 400, deliberately, because Reddit's logged-out front page is a different feed belonging to nobody rather than a thinner version of yours. Same post shape and `after` cursor as reddit_subreddit_posts. For a PUBLIC community feed use reddit_subreddit_posts instead. Example: sort='best' limit=25.",
254
+ shape: {
255
+ ...SESSION_HEADERS_REQUIRED,
256
+ sort: z.enum(["best", "hot", "new", "top", "rising", "controversial"]).optional().describe(
257
+ "Feed sort. 'best' (default) is Reddit's own logged-in home sort. 'top'/'controversial' also take `t`.",
258
+ ),
259
+ ...TIME,
260
+ ...AFTER,
261
+ ...LIMIT,
262
+ },
263
+ },
214
264
  {
215
265
  name: "reddit_search",
216
266
  path: "/api/reddit/search",
@@ -692,6 +742,46 @@ export const TOOLS = [
692
742
  ),
693
743
  },
694
744
  },
745
+ {
746
+ name: "reddit_feedback_list",
747
+ path: "/feedback",
748
+ description:
749
+ "List the feedback reports this account has sent, newest first, with each one's current status. Use it to RECOVER A LOST ID: the server id is returned only once, when a report is sent, so this is the way back to a report whose id was not kept. Also the way to answer \"did that report actually land\" and \"has the team looked at it yet\". Optionally filter by status or type, and page with the cursor from a previous response. Free per call, never metered. Returns {feedback: [...], count, limit, next_cursor}; page by passing next_cursor back as cursor until it is null; an account that has filed nothing gets an empty list and a 200, not an error. Note this lists SENT reports on the server, which is different from reddit_feedback_send action=\"list\", which shows unsent local drafts on this machine.",
750
+ shape: {
751
+ status: z.enum(["new", "triaged", "shipped", "declined"]).optional().describe(
752
+ "Optional. Show only reports in this state. Omit for all of them.",
753
+ ),
754
+ type: z.enum(["bug", "idea", "missing_capability"]).optional().describe(
755
+ "Optional. Show only reports of this kind. Omit for all of them.",
756
+ ),
757
+ cursor: z.string().optional().describe(
758
+ "Optional. The next_cursor from a previous response, to fetch the page after it. Keyset paging on (created_at, id), so a report filed while you page cannot make a row repeat or be skipped. A cursor this endpoint did not issue is a 400, never an empty page.",
759
+ ),
760
+ limit: z.number().int().min(1).max(100).optional().describe(
761
+ "Optional. How many to return, 1 to 100 (default 25). Newest first.",
762
+ ),
763
+ },
764
+ },
765
+ {
766
+ // CHECK BEFORE YOU SPEND. An agent planning a costed run has no way to ask
767
+ // how much credit is left, so it either runs blind and hits a 402 partway
768
+ // through, or it asks the user to go and look. Both are avoidable: the
769
+ // endpoint already exists and is already free.
770
+ //
771
+ // GET /account/me is mounted OUTSIDE /api (server.js), ahead of bearerAuth,
772
+ // under a comment reading "Free account endpoints live in apps/backend". So
773
+ // this tool bills nothing and is safe to call before every plan.
774
+ //
775
+ // Path verified against the live origin rather than read off the route
776
+ // tree: /account/me returns 401 unauthenticated while /account/<nonsense>
777
+ // returns 404, which is how we know the route exists. (/api/<anything>
778
+ // returns 401 for everything, so that path could not have told us.)
779
+ name: "reddit_account_me",
780
+ path: "/account/me",
781
+ description:
782
+ "How much credit this API key has left, before spending any. Returns the account's remaining credit balance and usage totals. FREE: this call is not metered and never costs a credit, so call it whenever you are about to run something expensive rather than guessing. Use it to decide whether a planned batch fits in the remaining balance, and to tell the user how much is left if a call returns 402. A 402 from any other tool means the balance is exhausted; its response carries a top-up URL to give the user.",
783
+ shape: {},
784
+ },
695
785
  {
696
786
  name: "reddit_feedback_get",
697
787
  path: "/feedback/{id}",
@@ -706,6 +796,55 @@ export const TOOLS = [
706
796
  ];
707
797
 
708
798
  // Turn tool args into a URL query string. Skips undefined/null/empty values.
799
+ // ── the session HEADER transport ────────────────────────────────────────────
800
+ //
801
+ // A GET tool's args normally become the query string. A Reddit session cookie
802
+ // must not: it grants full control of that account and a query string is
803
+ // written into every access log, proxy log and browser history along the path.
804
+ // So a tool that declares `sessionHeaders: true` has these arg names lifted OUT
805
+ // of the query and sent as headers instead.
806
+ //
807
+ // The four legacy private listings (upvoted/saved/hidden/gilded) deliberately
808
+ // do NOT declare it: they have always sent cookies as query fields, the REST
809
+ // API still honours that form, and changing what a shipped tool puts on the
810
+ // wire is a behaviour change nobody asked for. New tools use headers.
811
+ export const SESSION_ARG_TO_HEADER = {
812
+ reddit_session: "x-reddit-session",
813
+ loid: "x-reddit-loid",
814
+ token_v2: "x-reddit-token-v2",
815
+ csrf_token: "x-reddit-csrf-token",
816
+ edgebucket: "x-reddit-edgebucket",
817
+ csv: "x-reddit-csv",
818
+ session_tracker: "x-reddit-session-tracker",
819
+ pc: "x-reddit-pc",
820
+ proxy: "x-reddit-proxy",
821
+ };
822
+
823
+ /**
824
+ * Split a tool's args into headers and everything else.
825
+ *
826
+ * Returns { headers, rest }. When the tool does not declare `sessionHeaders`
827
+ * this is the identity: `headers` is empty and `rest` is the args unchanged, so
828
+ * every existing tool builds exactly the request it built before.
829
+ */
830
+ export function buildHeaders(tool, args) {
831
+ const headers = {};
832
+ const rest = {};
833
+ const lift = tool && tool.sessionHeaders;
834
+ for (const [k, v] of Object.entries(args || {})) {
835
+ const header = lift ? SESSION_ARG_TO_HEADER[k] : null;
836
+ if (header && v !== undefined && v !== null && String(v).length > 0) {
837
+ headers[header] = String(v);
838
+ } else if (!header) {
839
+ rest[k] = v;
840
+ }
841
+ // A declared session field that is empty is dropped rather than sent as an
842
+ // empty header: an empty x-reddit-session would read as "authenticate me"
843
+ // and earn a 400 instead of the anonymous read the caller meant.
844
+ }
845
+ return { headers, rest };
846
+ }
847
+
709
848
  export function buildQuery(args) {
710
849
  const qs = new URLSearchParams();
711
850
  for (const [k, v] of Object.entries(args || {})) {