redditapis-mcp 0.4.0 → 0.5.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/CHANGELOG.md +22 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/index.js +41 -16
- package/src/tools.js +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 (2026-09-06)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`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.
|
|
8
|
+
- **`reddit_verify_comments`, verify up to a hundred comments in one call.**
|
|
9
|
+
- **`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.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- The stuck-lock test used the wrong queue-directory env var and wrote to the real home directory instead of its fixture.
|
|
15
|
+
- `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.
|
|
16
|
+
|
|
17
|
+
### Why a minor rather than a patch
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
### Note on the gap this closes
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
3
25
|
## 0.4.0 (2026-09-04)
|
|
4
26
|
|
|
5
27
|
### 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. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redditapis-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.redditapis/redditapis-mcp",
|
|
5
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.",
|
|
6
6
|
"repository": {
|
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,26 @@ 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
|
+
},
|
|
695
765
|
{
|
|
696
766
|
name: "reddit_feedback_get",
|
|
697
767
|
path: "/feedback/{id}",
|
|
@@ -706,6 +776,55 @@ export const TOOLS = [
|
|
|
706
776
|
];
|
|
707
777
|
|
|
708
778
|
// Turn tool args into a URL query string. Skips undefined/null/empty values.
|
|
779
|
+
// ── the session HEADER transport ────────────────────────────────────────────
|
|
780
|
+
//
|
|
781
|
+
// A GET tool's args normally become the query string. A Reddit session cookie
|
|
782
|
+
// must not: it grants full control of that account and a query string is
|
|
783
|
+
// written into every access log, proxy log and browser history along the path.
|
|
784
|
+
// So a tool that declares `sessionHeaders: true` has these arg names lifted OUT
|
|
785
|
+
// of the query and sent as headers instead.
|
|
786
|
+
//
|
|
787
|
+
// The four legacy private listings (upvoted/saved/hidden/gilded) deliberately
|
|
788
|
+
// do NOT declare it: they have always sent cookies as query fields, the REST
|
|
789
|
+
// API still honours that form, and changing what a shipped tool puts on the
|
|
790
|
+
// wire is a behaviour change nobody asked for. New tools use headers.
|
|
791
|
+
export const SESSION_ARG_TO_HEADER = {
|
|
792
|
+
reddit_session: "x-reddit-session",
|
|
793
|
+
loid: "x-reddit-loid",
|
|
794
|
+
token_v2: "x-reddit-token-v2",
|
|
795
|
+
csrf_token: "x-reddit-csrf-token",
|
|
796
|
+
edgebucket: "x-reddit-edgebucket",
|
|
797
|
+
csv: "x-reddit-csv",
|
|
798
|
+
session_tracker: "x-reddit-session-tracker",
|
|
799
|
+
pc: "x-reddit-pc",
|
|
800
|
+
proxy: "x-reddit-proxy",
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Split a tool's args into headers and everything else.
|
|
805
|
+
*
|
|
806
|
+
* Returns { headers, rest }. When the tool does not declare `sessionHeaders`
|
|
807
|
+
* this is the identity: `headers` is empty and `rest` is the args unchanged, so
|
|
808
|
+
* every existing tool builds exactly the request it built before.
|
|
809
|
+
*/
|
|
810
|
+
export function buildHeaders(tool, args) {
|
|
811
|
+
const headers = {};
|
|
812
|
+
const rest = {};
|
|
813
|
+
const lift = tool && tool.sessionHeaders;
|
|
814
|
+
for (const [k, v] of Object.entries(args || {})) {
|
|
815
|
+
const header = lift ? SESSION_ARG_TO_HEADER[k] : null;
|
|
816
|
+
if (header && v !== undefined && v !== null && String(v).length > 0) {
|
|
817
|
+
headers[header] = String(v);
|
|
818
|
+
} else if (!header) {
|
|
819
|
+
rest[k] = v;
|
|
820
|
+
}
|
|
821
|
+
// A declared session field that is empty is dropped rather than sent as an
|
|
822
|
+
// empty header: an empty x-reddit-session would read as "authenticate me"
|
|
823
|
+
// and earn a 400 instead of the anonymous read the caller meant.
|
|
824
|
+
}
|
|
825
|
+
return { headers, rest };
|
|
826
|
+
}
|
|
827
|
+
|
|
709
828
|
export function buildQuery(args) {
|
|
710
829
|
const qs = new URLSearchParams();
|
|
711
830
|
for (const [k, v] of Object.entries(args || {})) {
|