redditapis-mcp 0.3.2 → 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 CHANGED
@@ -1,53 +1,43 @@
1
1
  # Changelog
2
2
 
3
- ## 0.3.2
4
- - **Security: 10 Dependabot alerts closed (3 high, 6 moderate, 1 low)**, all in
5
- transitive dependencies pulled in by `@modelcontextprotocol/sdk` (never a
6
- direct dependency of this package): `hono` (SSR output cross-user
7
- disclosure, ReDoS in CORS/language middleware, header handling in the proxy
8
- helper), `fast-uri` (host confusion via a backslash authority delimiter),
9
- `ip-address` (three separate SSRF/trust-boundary bypasses via octal,
10
- IPv4-mapped, and CIDR-suffix address confusion), and `@hono/node-server`
11
- (path traversal on Windows). Fixed by a clean reinstall against the
12
- existing `@modelcontextprotocol/sdk: ^1.0.0` range, which already permitted
13
- the patched versions; no version constraint changed, only the resolved
14
- tree (SDK 1.29.0 to 1.30.0, hono to 4.13.2, fast-uri to 3.1.5, ip-address
15
- to 10.5.0, @hono/node-server to 2.1.1). This server only imports the SDK's
16
- stdio transport (confirmed: `src/index.js` never imports `hono` or the
17
- SDK's HTTP transport), so `hono` and `@hono/node-server` never load at
18
- runtime here; `fast-uri` and `ip-address` sit deeper in the SDK's own
19
- dependency tree and were not individually traced. Fixed regardless, since
20
- Dependabot flags them independent of reachability and a clean scan is
21
- the point.
22
-
23
- ## 0.3.1
24
- - **Fix: the server no longer exits at startup when `REDDITAPIS_KEY` is missing.**
25
- A registry connectivity scanner (Smithery, Glama, the official MCP registry,
26
- Claude Connectors Directory) spins up the server with no real credential to
27
- enumerate `tools/list`. Exiting before the transport connects made every
28
- automated scan fail outright and read as a generic connectivity error rather
29
- than a missing-key error, which is most of why this listing scored low on
30
- every registry's capability-quality checks. Tools now register and
31
- `tools/list` responds regardless of whether a key is present; an actual tool
32
- call with no key still fails clearly, at the point of the call, with the
33
- same style of message the existing 401 branch already used.
34
- - Adds `server.json` (official MCP server manifest: description, homepage,
35
- repository, npm package identifier, and the `REDDITAPIS_KEY` config field
36
- with a title and description) to the repo and to the published package, so
37
- registry submissions have a durable, versioned source instead of a local,
38
- never-committed file.
39
-
40
- ## 0.3.0
41
- - 4 new READ tools for the caller's own private Reddit listings (upvoted,
42
- saved, hidden, gilded), each requiring the caller's own Reddit session
43
- cookie obtained via `POST /api/reddit/login`.
44
- - Pagination docs clarified across every listing tool: `listing_status` on the
45
- final page distinguishes `complete` (nothing missing) from `truncated`/
46
- `unknown` (Reddit stopped serving a busy listing early), so a client no
47
- longer reads a null `after` cursor as proof of a complete result set.
48
- - This CHANGELOG entry is retroactive; 0.3.0 published without one, along with
49
- drifting one npm publish ahead of what GitHub had committed. Both are
50
- corrected as part of the 0.3.1 pass.
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
+
25
+ ## 0.4.0 (2026-09-04)
26
+
27
+ ### Added
28
+
29
+ - **`reddit_feedback_send` and `reddit_feedback_get`, report a bug or a gap to the redditapis.com team from inside the session you are already in.** Modelled on Claude Code's own feedback tool: the model drafts a report at a high-signal moment (a call failed in a way that is not your key, credits or a rate limit and you had to work around it; you asked for something no tool covers; a documented field came back empty or wrong; you were plainly frustrated with a result) into a local queue at `~/.redditapis/feedback-queue.json` (override with `REDDITAPIS_FEEDBACK_DIR`, at most 10 drafts), and nothing is sent until you review the queue and name the drafts to send. Each draft carries the last failing call's endpoint, status and request id, your MCP client's name and this package's version, filled in automatically, so a report is actionable without a follow-up. `reddit_feedback_send` takes `action` (`draft`, `list`, `send`, `discard`); `reddit_feedback_get` reads a sent report's status and the team's response. Both are free and need only your API key. The trigger list also ships as the server's MCP `instructions`, so a client that honours them nudges its model at the right moments, and every non-credential error body (anything but 401/402/429) now ends with a one-line pointer to the tool. This takes the catalog to 38 tools (34 documented in the table above plus the 4 cookie-authenticated user-history reads).
30
+ - **Catalog support for local handlers.** A tool may declare `local: "<handler>"` in `src/tools.js` and name its local-only args in `localArgs`; those args are consumed in this package and never sent to the API. `src/index.js` refuses to boot if a catalog entry names a handler it does not implement, and the catalog test pins that every `localArgs` entry is a real key of the tool's shape, so neither flag can turn into a silent passthrough.
31
+
32
+ ### Fixed
33
+
34
+ - `test/smoke.mjs` asserted a tool count of 32 against a catalog of 36; it now derives the expected count from the catalog it lists.
35
+
36
+ ## 0.3.0 (2026-08-16)
37
+
38
+ ### Added
39
+
40
+ - **`exclude_subreddits` and `exclude_terms` on `reddit_monitor_add` and `reddit_monitor_update`.** Per-monitor subreddit exclusion for sitewide keyword watches, and term exclusion for every monitor. Published as a minor rather than a patch because a real capability was added; anyone pinned to 0.2.x opts in rather than receiving it silently. (This entry was written retroactively on 2026-09-04: the version shipped with no changelog line.)
51
41
 
52
42
  ## 0.2.0
53
43
  - **10 new monitor/webhook management tools** (task #43), the first WRITES this
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/redditapis-mcp)](https://www.npmjs.com/package/redditapis-mcp)
5
5
  [![license](https://img.shields.io/npm/l/redditapis-mcp)](./LICENSE)
6
6
 
7
- Official **Model Context Protocol** server for [redditapis.com](https://www.redditapis.com), the Reddit API as native tools for Claude, Cursor, Windsurf, and any MCP client. It turns Reddit reads (search, subreddit listings, comment trees, user profiles, community metadata) into typed tools your agent can call directly, plus (since 0.2.0) managing your own redditapis.com monitors and webhooks.
7
+ Official **Model Context Protocol** server for [redditapis.com](https://www.redditapis.com), the Reddit API as native tools for Claude, Cursor, Windsurf, and any MCP client. It turns Reddit reads (search, subreddit listings, comment trees, user profiles, community metadata) into typed tools your agent can call directly, plus (since 0.2.0) managing your own redditapis.com monitors and webhooks, and (since 0.4.0) sending the team product feedback the agent drafted and you reviewed.
8
8
 
9
9
  Ask your agent to search Reddit for a topic, read a community's top posts of the week, pull a user's comment history, surface the redditors talking about a product, or read a subreddit's rules before you engage, and it calls the API for you. It can also set up a monitor that watches a subreddit for new posts matching a filter and delivers them to a webhook, then check what it's actually delivered. Every tool maps to a REST endpoint at `https://api.redditapis.com`; the server holds no state and forwards your API key on each call.
10
10
 
@@ -94,12 +94,13 @@ claude mcp add reddit --env REDDITAPIS_KEY=YOUR_API_KEY -- npx -y redditapis-mcp
94
94
  | `REDDITAPIS_KEY` | Yes | (none) | API key from [redditapis.com](https://www.redditapis.com). `REDDIT_APIS_KEY` is accepted as an alias. |
95
95
  | `REDDITAPIS_BASE_URL` | No | `https://api.redditapis.com` | Override the API host. |
96
96
  | `REDDITAPIS_TIMEOUT_MS` | No | `30000` | Per-request timeout in milliseconds. |
97
+ | `REDDITAPIS_FEEDBACK_DIR` | No | `~/.redditapis` | Where `reddit_feedback_send` keeps its local draft queue (`feedback-queue.json`). |
97
98
 
98
99
  Authentication is a Bearer token: the server sends `Authorization: Bearer <REDDITAPIS_KEY>` on every request.
99
100
 
100
101
  ## Tools
101
102
 
102
- 32 tools: 22 reads plus 10 monitor/webhook management tools. Reddit writes (posting, commenting, voting, DMs) remain a separate authenticated surface and are intentionally out of scope here -- monitor/webhook tools configure your OWN redditapis.com account (an alerting subscription), never Reddit itself. Every read works with just your API key; the 6 monitor/webhook writes additionally need an active monitoring plan (see Monitoring below).
103
+ 34 tools: 22 reads, 10 monitor/webhook management tools, and 2 feedback tools. Reddit writes (posting, commenting, voting, DMs) remain a separate authenticated surface and are intentionally out of scope here -- monitor/webhook tools configure your OWN redditapis.com account (an alerting subscription), never Reddit itself, and the feedback tools send a report to the redditapis.com team, never to Reddit. Every read works with just your API key; the 6 monitor/webhook writes additionally need an active monitoring plan (see Monitoring below). The feedback tools are free and need only your key.
103
104
 
104
105
  A few conventions across the catalog:
105
106
 
@@ -124,6 +125,9 @@ A few conventions across the catalog:
124
125
  | Tool | Endpoint | What it does |
125
126
  |---|---|---|
126
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. |
127
131
  | `reddit_subreddit_top` | `GET /api/reddit/sub/{name}/top` | Top posts of a subreddit for a time window (`t`). |
128
132
  | `reddit_subreddit_comments` | `GET /api/reddit/sub/{name}/comments` | Stream the newest comments across an entire subreddit (not one post's thread). |
129
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. |
@@ -172,6 +176,15 @@ v1 monitors are **subreddit-scoped, posts-only** (no all-of-Reddit keyword watch
172
176
  | `reddit_monitor_webhook_test` | `POST /api/reddit/monitor/webhook/test` | Send a one-off test delivery to confirm a webhook is wired up correctly. |
173
177
  | `reddit_monitor_webhook_delete` | `POST /api/reddit/monitor/webhook/delete` | Permanently delete a webhook. Does not cascade-pause monitors still pointing at it. |
174
178
 
179
+ ### Feedback: tell the team what broke, after you review the draft
180
+
181
+ Modelled on Claude Code's own feedback tool. When a call fails in a way that is not your key, credits or a rate limit, when you ask for something no tool covers, or when a result is plainly wrong, the model can **draft** a report into a local queue (`~/.redditapis/feedback-queue.json`, at most 10 drafts, override the directory with `REDDITAPIS_FEEDBACK_DIR`). Nothing is sent until you ask to review the queue and name the drafts to send. Each report carries the last failing call's endpoint, status and request id, your client name and this package's version, so the team can act on it without a follow-up. Use `reddit_feedback_get` with the returned server id to see whether it was triaged, shipped or declined. Both tools are free.
182
+
183
+ | Tool | Endpoint | What it does |
184
+ |------|----------|--------------|
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_feedback_get` | `GET /feedback/{id}` | Read a sent report's status (`new`, `triaged`, `shipped`, `declined`) and the team's response. |
187
+
175
188
  ## Usage examples
176
189
 
177
190
  ### Research a topic across Reddit
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
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) as native tools for Claude, Cursor, and any MCP client.",
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": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/redditapis/redditapis-mcp.git"
@@ -18,16 +18,15 @@
18
18
  "README.md",
19
19
  "LICENSE",
20
20
  "CHANGELOG.md",
21
- "smithery.yaml",
22
- "server.json"
21
+ "smithery.yaml"
23
22
  ],
24
23
  "engines": {
25
24
  "node": ">=18"
26
25
  },
27
26
  "scripts": {
28
27
  "start": "node src/index.js",
29
- "check": "node --check src/index.js && node --check src/tools.js",
30
- "test": "node test/tools.test.mjs",
28
+ "check": "node --check src/index.js && node --check src/tools.js && node --check src/feedback.js",
29
+ "test": "node test/tools.test.mjs && node test/feedback.test.mjs && node test/index-lasterror.test.mjs",
31
30
  "prepublishOnly": "node scripts/prepublish-tenant-check.mjs"
32
31
  },
33
32
  "dependencies": {
@@ -0,0 +1,287 @@
1
+ // src/feedback.js, the local half of reddit_feedback_send.
2
+ //
3
+ // Claude Code's own feedback tool works like this: the model drafts a report at
4
+ // a high-signal moment, the harness writes it to a LOCAL queue, and nothing
5
+ // leaves the machine until the user reviews the queue and approves. That
6
+ // consent step is what makes it safe to let a model draft freely. This module
7
+ // gives redditapis.com customers the same loop inside whatever MCP client
8
+ // they already use: action "draft" appends to ~/.redditapis/feedback-queue.json
9
+ // and sends nothing; "list" shows the drafts; "send" posts ONLY the ids the user
10
+ // named to POST /feedback (free, not metered); "discard" drops them.
11
+ //
12
+ // The tool DESCRIPTION in src/tools.js is the product: it names
13
+ // the trigger moments and the four-bullet format. This file enforces the same
14
+ // rules mechanically so a draft that reaches the server is well-formed, and it
15
+ // auto-attaches the evidence the server already holds (the last failing call,
16
+ // the client name from the MCP handshake, this package's version), so the model
17
+ // never has to type identifiers it might get wrong.
18
+ //
19
+ // STATE ON DISK, ON PURPOSE. The queue outlives the session: a user can review
20
+ // tomorrow what an agent drafted today. Writes are atomic (temp file + rename)
21
+ // because two MCP clients can share one home directory.
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+
28
+ export const QUEUE_CAP = 10;
29
+ export const TYPES = ["bug", "idea", "missing_capability"];
30
+ export const ACTIONS = ["draft", "list", "send", "discard"];
31
+ const MAX = { title: 120, details: 8000, area: 80 };
32
+ const EVIDENCE_MAX_BYTES = 4096;
33
+
34
+ /** Where drafts live. REDDITAPIS_FEEDBACK_DIR overrides the default ~/.redditapis. */
35
+ export function queuePath(env = process.env) {
36
+ const dir = env.REDDITAPIS_FEEDBACK_DIR || join(homedir(), ".redditapis");
37
+ return join(dir, "feedback-queue.json");
38
+ }
39
+
40
+ /** A draft this module can act on. Anything else in the file (a hand edit, an
41
+ * older shape, a null) is skipped rather than allowed to throw on every call. */
42
+ function isDraft(d) {
43
+ return d && typeof d === "object" && !Array.isArray(d)
44
+ && typeof d.id === "string" && typeof d.type === "string"
45
+ && typeof d.title === "string" && typeof d.details === "string";
46
+ }
47
+
48
+ export function readQueue(path) {
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
51
+ return Array.isArray(parsed?.drafts) ? parsed.drafts.filter(isDraft) : [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ function writeQueue(path, drafts) {
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
60
+ writeFileSync(tmp, JSON.stringify({ version: 1, drafts }, null, 2) + "\n");
61
+ renameSync(tmp, path);
62
+ }
63
+
64
+ // Two MCP servers can share one home directory, and temp+rename only keeps the
65
+ // file well-formed: without a lock a read-modify-write from each loses one
66
+ // side's drafts (measured 2026-09-04: two writers, five of ten drafts gone).
67
+ // mkdir is atomic on every platform node runs on, so a lock DIRECTORY is the
68
+ // mutex; a holder that died leaves it behind, so one older than STALE_MS is
69
+ // reclaimed.
70
+ const LOCK_STALE_MS = 10_000;
71
+ const LOCK_WAIT_MS = 3_000;
72
+ async function withLock(path, fn) {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ const lock = `${path}.lock`;
75
+ const deadline = Date.now() + LOCK_WAIT_MS;
76
+ for (;;) {
77
+ try {
78
+ mkdirSync(lock);
79
+ break;
80
+ } catch (err) {
81
+ if (err?.code !== "EEXIST") throw err;
82
+ try {
83
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { rmSync(lock, { recursive: true, force: true }); continue; }
84
+ } catch { /* vanished between checks; retry */ }
85
+ if (Date.now() > deadline) throw new Error(`feedback queue is locked by another process (${lock}); retry in a moment`);
86
+ await new Promise((r) => setTimeout(r, 25 + Math.floor(Math.random() * 50)));
87
+ }
88
+ }
89
+ try {
90
+ return await fn();
91
+ } finally {
92
+ rmSync(lock, { recursive: true, force: true });
93
+ }
94
+ }
95
+
96
+ /** Stable per-issue id: the same type + title redrafted replaces itself. */
97
+ export function draftId(type, title) {
98
+ return createHash("sha256").update(`${type}\n${title.trim().toLowerCase()}`).digest("hex").slice(0, 8);
99
+ }
100
+
101
+ const CLIENT_MAX = 120; // the backend rejects longer; the model never types this field
102
+ function clientString(clientInfo, version) {
103
+ const name = clientInfo?.name ? `${clientInfo.name}${clientInfo.version ? `/${clientInfo.version}` : ""}` : "unknown-client";
104
+ return `${name} via redditapis-mcp@${version}`.slice(0, CLIENT_MAX);
105
+ }
106
+
107
+ // The last failing call is worth attaching only while it is plausibly the call
108
+ // the draft is about: recent, and not the feedback endpoint's own failure.
109
+ const LAST_ERROR_TTL_MS = 10 * 60 * 1000;
110
+ function usableLastError(last) {
111
+ if (!last || typeof last !== "object") return null;
112
+ if (typeof last.ts === "number" && Date.now() - last.ts > LAST_ERROR_TTL_MS) return null;
113
+ if (last.path === "/feedback" || (typeof last.path === "string" && last.path.startsWith("/feedback/"))) return null;
114
+ return last;
115
+ }
116
+
117
+ /** Does a recorded failure describe THIS tool's call? BOTH method and path
118
+ * must match: a GET and a POST can share a path, and naming the wrong tool on
119
+ * a draft sends the team chasing the wrong handler. Exported so the rule is
120
+ * unit-tested here rather than only reachable through a live stdio boot. */
121
+ export function sameFailure(last, resolvedPath, method) {
122
+ return Boolean(last) && resolvedPath === last.path && method === last.method;
123
+ }
124
+
125
+ const text = (t, isError = false) => ({ isError, content: [{ type: "text", text: t }] });
126
+
127
+ function summarize(d) {
128
+ const first = d.details.split("\n")[0].slice(0, 140);
129
+ return `${d.id} [${d.type}] ${d.title}${d.area ? ` (${d.area})` : ""} drafted ${d.ts}\n ${first}`;
130
+ }
131
+
132
+ /**
133
+ * @param {object} deps
134
+ * @param {(path:string, args:object, method:string)=>Promise<object>} deps.callEndpoint
135
+ * index.js's callEndpoint: a POST sends `args` as the JSON body.
136
+ * @param {string} deps.version this package's version
137
+ * @param {() => ({name?:string, version?:string}|undefined)} [deps.getClientInfo]
138
+ * @param {() => (object|null)} [deps.getLastError] the last failing tool call, if any
139
+ * @param {object} [deps.env]
140
+ */
141
+ export function createFeedbackHandler({ callEndpoint, version, getClientInfo, getLastError, env = process.env }) {
142
+ return async function feedbackTool(args = {}) {
143
+ const action = args.action || "draft";
144
+ if (!ACTIONS.includes(action)) return text(`action must be one of ${ACTIONS.join(", ")}.`, true);
145
+ const path = queuePath(env);
146
+ try {
147
+ if (action === "send") return await sendAction(args, path);
148
+ return await withLock(path, () => run(action, args, path));
149
+ } catch (err) {
150
+ return text(err?.message || String(err), true);
151
+ }
152
+ };
153
+
154
+ // Remove exactly these ids from the queue ON DISK: lock, re-read, filter,
155
+ // write, release. Never from a snapshot taken earlier, so a draft another
156
+ // process added in the meantime survives.
157
+ async function removeFromQueue(path, ids) {
158
+ await withLock(path, () => {
159
+ const now = readQueue(path);
160
+ writeQueue(path, now.filter((x) => !ids.includes(x.id)));
161
+ });
162
+ }
163
+
164
+ // NEVER HOLD THE LOCK ACROSS A NETWORK CALL (review 2026-09-04, OBSERVED):
165
+ // LOCK_STALE_MS is 10s and a request may take 30s, so a send that held the
166
+ // lock for the whole batch was reclaimed as stale by a concurrent drafter,
167
+ // and the sender's pre-send snapshot then overwrote that draft. So: under
168
+ // the lock, read and pick; release; post each draft with no lock held; after
169
+ // each success take the lock again, RE-READ the queue and remove exactly
170
+ // that id. A crash mid-batch still never resends a posted draft, and a
171
+ // draft added during the batch is never lost.
172
+ async function sendAction(args, path) {
173
+ const picked = await withLock(path, () => {
174
+ const drafts = readQueue(path);
175
+ const ids = [...new Set((Array.isArray(args.ids) ? args.ids : []).map(String))];
176
+ if (ids.length === 0) return { error: text(`action "send" needs ids: the draft ids the user named (from action "list").`, true) };
177
+ const unknown = ids.filter((id) => !drafts.some((d) => d.id === id));
178
+ if (unknown.length) return { error: text(`Unknown draft id(s): ${unknown.join(", ")}. Run action "list" to see the current ids.`, true) };
179
+ return { drafts: ids.map((id) => drafts.find((d) => d.id === id)) };
180
+ });
181
+ if (picked.error) return picked.error;
182
+
183
+ const sent = [];
184
+ const failed = [];
185
+ const sentIds = [];
186
+ const stuck = [];
187
+ for (const d of picked.drafts) {
188
+ const body = { type: d.type, title: d.title, details: d.details, evidence: d.evidence, client: d.client };
189
+ if (d.area) body.area = d.area;
190
+ const res = await callEndpoint("/feedback", body, "POST");
191
+ const out = res?.content?.[0]?.text ?? "";
192
+ if (res?.isError) {
193
+ failed.push(`${d.id}: ${out.slice(0, 300)}`);
194
+ continue;
195
+ }
196
+ let serverId = null;
197
+ try { serverId = JSON.parse(out).id ?? null; } catch { /* body was not JSON; keep null */ }
198
+ sent.push(`${d.id} -> ${serverId ?? "sent"}`);
199
+ sentIds.push(d.id);
200
+ // Persist after EACH success, from a fresh read of the file, so a process
201
+ // that dies mid-loop cannot re-send a report the server already holds. The
202
+ // server already holds this report, so a lock that cannot be re-taken (a
203
+ // foreign holder past the wait) must NOT become an error that hides the
204
+ // server id and leaves the draft re-sendable: report it as posted and name
205
+ // the draft to discard.
206
+ try {
207
+ await removeFromQueue(path, [d.id]);
208
+ } catch (err) {
209
+ stuck.push(`${d.id} (server id ${serverId ?? "unknown"}): ${err?.message || String(err)}`);
210
+ }
211
+ }
212
+ const remaining = readQueue(path).length;
213
+ const lines = [];
214
+ if (sent.length) lines.push(`Sent ${sent.length} report(s) to redditapis.com (free, not metered):\n ${sent.join("\n ")}\nCheck one later with reddit_feedback_get using the server id.`);
215
+ if (failed.length) lines.push(`${failed.length} draft(s) stayed in the queue because the send failed:\n ${failed.join("\n ")}`);
216
+ if (stuck.length) lines.push(`${stuck.length} report(s) WERE posted but the local draft could not be removed (the queue was locked). Do not send these ids again; remove them with action "discard":\n ${stuck.join("\n ")}`);
217
+ lines.push(`${remaining} draft(s) still pending.`);
218
+ return text(lines.join("\n\n"), sent.length === 0);
219
+ }
220
+
221
+ async function run(action, args, path) {
222
+ if (action === "draft") {
223
+ if (!TYPES.includes(args.type)) return text(`type is required for a draft and must be one of ${TYPES.join(", ")}.`, true);
224
+ const title = String(args.title ?? "").trim();
225
+ if (!title || title.length > MAX.title) return text(`title is required for a draft, at most ${MAX.title} characters.`, true);
226
+ const details = String(args.details ?? "").trim();
227
+ if (!details || details.length > MAX.details) return text(`details is required for a draft, at most ${MAX.details} characters. Use the four labelled bullets: What happened, What the user said, Repro, Evidence.`, true);
228
+ const area = args.area ? String(args.area).trim().slice(0, MAX.area) : undefined;
229
+
230
+ const client = clientString(getClientInfo?.(), version);
231
+ const evidence = { ...(args.evidence && typeof args.evidence === "object" && !Array.isArray(args.evidence) ? args.evidence : {}) };
232
+ const last = usableLastError(getLastError?.());
233
+ // Fill only what the model did not supply: its own evidence wins.
234
+ if (last) {
235
+ if (evidence.tool === undefined && last.tool) evidence.tool = last.tool;
236
+ if (evidence.endpoint === undefined && last.path) evidence.endpoint = last.path;
237
+ if (evidence.status === undefined && last.status) evidence.status = last.status;
238
+ if (evidence.request_id === undefined && last.requestId) evidence.request_id = last.requestId;
239
+ }
240
+ evidence.mcp_version = version;
241
+ evidence.client = client;
242
+ if (Buffer.byteLength(JSON.stringify(evidence), "utf8") > EVIDENCE_MAX_BYTES) {
243
+ return text(`evidence must serialize to ${EVIDENCE_MAX_BYTES} bytes or fewer. Send identifiers (tool, endpoint, status, request id), never payloads.`, true);
244
+ }
245
+
246
+ const drafts = readQueue(path);
247
+ const draft = { id: draftId(args.type, title), ts: new Date().toISOString(), type: args.type, title, details, area, evidence, client };
248
+ const idx = drafts.findIndex((d) => d.id === draft.id);
249
+ if (idx >= 0) {
250
+ drafts[idx] = draft;
251
+ } else {
252
+ if (drafts.length >= QUEUE_CAP) {
253
+ return text(`The local feedback queue already holds ${QUEUE_CAP} drafts. Ask the user to review them (action "list", then "send" or "discard") before drafting more.`, true);
254
+ }
255
+ drafts.push(draft);
256
+ }
257
+ writeQueue(path, drafts);
258
+ return text(
259
+ `Queued locally as draft ${draft.id}${idx >= 0 ? " (replaced an earlier draft with the same title)" : ""}. ${drafts.length} draft(s) pending in ${path}. ` +
260
+ `Nothing was sent and nothing needs to be said to the user right now. When the user asks to review or send feedback, call this tool with action "list", ` +
261
+ `then action "send" with only the ids the user names, or "discard".`,
262
+ );
263
+ }
264
+
265
+ const drafts = readQueue(path);
266
+
267
+ if (action === "list") {
268
+ if (drafts.length === 0) return text(`No feedback drafts pending (${path}).`);
269
+ return text(
270
+ `${drafts.length} feedback draft(s) pending in ${path}. Show these to the user and send only the ids they name:\n\n` +
271
+ drafts.map(summarize).join("\n"),
272
+ );
273
+ }
274
+
275
+ const ids = [...new Set((Array.isArray(args.ids) ? args.ids : []).map(String))];
276
+ if (ids.length === 0) return text(`action "${action}" needs ids: the draft ids the user named (from action "list").`, true);
277
+ const unknown = ids.filter((id) => !drafts.some((d) => d.id === id));
278
+ if (unknown.length) return text(`Unknown draft id(s): ${unknown.join(", ")}. Run action "list" to see the current ids.`, true);
279
+
280
+ if (action === "discard") {
281
+ const kept = drafts.filter((d) => !ids.includes(d.id));
282
+ writeQueue(path, kept);
283
+ return text(`Discarded ${ids.length} draft(s): ${ids.join(", ")}. ${kept.length} still pending.`);
284
+ }
285
+
286
+ }
287
+ }
package/src/index.js CHANGED
@@ -3,25 +3,31 @@
3
3
  //
4
4
  // Exposes the Reddit API as native MCP tools for Claude, Cursor, and any MCP
5
5
  // client: subreddit listings, post + comment search, community/user/media/comment
6
- // search, a post's comment tree, subreddit top posts, user profile/comments, and
6
+ // search, a post's comment tree, subreddit top posts, user profile/comments,
7
7
  // (since 0.2.0) managing your own redditapis.com monitors and webhooks -- create/
8
8
  // list/update/remove a monitor, register/list/test/delete a webhook, and read
9
- // delivery history. Each tool is a thin, typed wrapper over a REST endpoint at
10
- // https://api.redditapis.com. The server holds no state and forwards your API
11
- // key on every call. The tool catalog lives in ./tools.js.
9
+ // delivery history -- and (since 0.4.0) sending the team product feedback the
10
+ // user has reviewed. Each tool is a thin, typed wrapper over a REST endpoint at
11
+ // https://api.redditapis.com, except reddit_feedback_send, whose draft queue
12
+ // lives on this machine (./feedback.js) and posts only on the user's say. The
13
+ // server holds no other state and forwards your API key on every call. The
14
+ // tool catalog lives in ./tools.js.
12
15
  //
13
16
  // Config (env):
14
17
  // REDDITAPIS_KEY required. Your key from https://www.redditapis.com
15
18
  // (REDDIT_APIS_KEY is accepted as an alias).
16
19
  // REDDITAPIS_BASE_URL optional. Defaults to https://api.redditapis.com
17
20
  // REDDITAPIS_TIMEOUT_MS optional. Per-request timeout (default 30000)
21
+ // REDDITAPIS_FEEDBACK_DIR optional. Where reddit_feedback_send keeps its
22
+ // local draft queue (default ~/.redditapis)
18
23
  //
19
24
  // Run: npx -y redditapis-mcp@latest (stdio transport)
20
25
 
21
26
  import { createRequire } from "node:module";
22
27
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
24
- import { TOOLS, buildQuery, buildPath, buildBody } from "./tools.js";
29
+ import { TOOLS, buildQuery, buildPath, buildBody, buildHeaders } from "./tools.js";
30
+ import { createFeedbackHandler, sameFailure } from "./feedback.js";
25
31
 
26
32
  const API_KEY = process.env.REDDITAPIS_KEY || process.env.REDDIT_APIS_KEY;
27
33
  const BASE_URL = (
@@ -33,37 +39,62 @@ const REQUEST_TIMEOUT_MS = Number(process.env.REDDITAPIS_TIMEOUT_MS || 30000);
33
39
  // User-Agent and MCP server handshake understated its own version for months.
34
40
  const { version: VERSION } = createRequire(import.meta.url)("../package.json");
35
41
 
36
- // Lazy validation, not exit-on-boot: an MCP registry scanner (Smithery, Glama,
37
- // the official registry, Claude Connectors) connects the stdio transport with
38
- // no real credential to enumerate tools/list. Exiting here before the server
39
- // ever registers a tool makes that handshake fail outright and reads as a
40
- // generic connectivity error, not a missing-key error, on the scanner side --
41
- // confirmed live 2026-08-18 (Smithery: "Initialization failed... could not be
42
- // automatically scanned", HTTP 404). Warn and continue; a real tool CALL made
43
- // with no key still fails clearly, at the point of the call, same as it
44
- // already does for a bad key (see the 401 branch below).
45
42
  if (!API_KEY) {
46
43
  console.error(
47
- "[reddit-mcp] Missing REDDITAPIS_KEY. Get a key at https://www.redditapis.com and set it in your MCP client config. Tools are registered but every call will fail until it is set.",
44
+ "[reddit-mcp] Missing REDDITAPIS_KEY. Get a key at https://www.redditapis.com and set it in your MCP client config.",
48
45
  );
46
+ process.exit(1);
49
47
  }
50
48
 
49
+ // The last tool call that failed, so a feedback draft can carry the endpoint,
50
+ // status and request id without the model retyping them. Set in callEndpoint's
51
+ // error branch; the tool name is added by the registration wrapper below.
52
+ let lastError = null;
53
+
51
54
  // REST call. Resolves any {param} path placeholders from args, and for GET
52
55
  // sends the rest as a query string; for POST it sends the rest as a JSON body
53
56
  // (see buildBody -- `tool` is passed through only to read its
54
57
  // `filterSpecFields`). Forwards the API key as a Bearer token (redditapis.com
55
58
  // authenticates via `Authorization: Bearer <key>` only).
56
- async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
57
- if (!API_KEY) {
58
- return {
59
- isError: true,
60
- content: [{
61
- type: "text",
62
- text: "Missing REDDITAPIS_KEY (invalid or missing API key, get one at https://www.redditapis.com and set it in your MCP client config).",
63
- }],
64
- };
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;
65
86
  }
66
- const { path, rest } = buildPath(pathTemplate, args);
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
+
92
+ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
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);
67
98
  const isWrite = method !== "GET";
68
99
  const q = isWrite ? "" : buildQuery(rest);
69
100
  const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
@@ -72,6 +103,7 @@ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
72
103
  Authorization: `Bearer ${API_KEY}`,
73
104
  accept: "application/json",
74
105
  "user-agent": `reddit-mcp/${VERSION}`,
106
+ ...sessionHeaders,
75
107
  };
76
108
  let requestBody;
77
109
  if (isWrite) {
@@ -85,32 +117,60 @@ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
85
117
  const res = await fetch(url, { method, headers, body: requestBody, signal: ctrl.signal });
86
118
  const body = await res.text();
87
119
  if (!res.ok) {
88
- const hint =
89
- res.status === 401
90
- ? " (invalid or missing API key, verify REDDITAPIS_KEY at https://www.redditapis.com)"
91
- : res.status === 402
92
- ? " (insufficient credits, top up at https://www.redditapis.com)"
93
- : res.status === 403
94
- ? " (access forbidden. The subreddit/user may be private, banned, or quarantined)"
95
- : res.status === 404
96
- ? " (not found. The subreddit, post id, user, or permalink may be wrong or deleted)"
97
- : res.status === 429
98
- ? " (rate limited. Wait a few seconds and retry, or reduce request frequency)"
99
- : res.status >= 500
100
- ? " (upstream API error. Retry in a moment; if persistent, contact https://www.redditapis.com)"
101
- : "";
102
- return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}` }] };
120
+ const hint = hintFor(res.status, path);
121
+ lastError = {
122
+ path,
123
+ method,
124
+ status: res.status,
125
+ requestId: res.headers.get("x-request-id") || undefined,
126
+ ts: Date.now(),
127
+ };
128
+ // Credential, credit, rate-limit and not-found failures are the caller's
129
+ // situation (a 404 is almost always a wrong id or a deleted thing, a 409
130
+ // a conflict with the caller's own state), not a product defect;
131
+ // everything else may be one, and the model reads error bodies closely,
132
+ // so the pointer lives here.
133
+ const feedbackHint =
134
+ res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
135
+ ? ""
136
+ : " If this blocked the user's task and looks like a defect or a missing capability, draft a report with reddit_feedback_send (queued locally until the user reviews it).";
137
+ return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
103
138
  }
139
+ // A success clears the record so a later draft never inherits an old
140
+ // failure's endpoint or request id (review 2026-09-04: a remove's draft
141
+ // carried the previous update's 404).
142
+ lastError = null;
104
143
  return { content: [{ type: "text", text: body }] };
105
144
  } catch (err) {
106
145
  const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
146
+ lastError = { path, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
107
147
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
108
148
  } finally {
109
149
  clearTimeout(timer);
110
150
  }
111
151
  }
112
152
 
113
- const server = new McpServer({ name: "redditapis", version: VERSION });
153
+ // Standing instructions the client hands its model alongside the tool list.
154
+ // This is the trigger list for feedback, in the place a model actually reads.
155
+ const INSTRUCTIONS =
156
+ "redditapis.com MCP server. Read tools cost credits per call (most $0.002); monitor, webhook and feedback tools are free. " +
157
+ "If a redditapis tool call fails with an error other than 401/402/429 and the user has to work around it, if the user asks for something no redditapis tool covers, " +
158
+ "if a documented field comes back empty or wrong, or if the user is clearly frustrated with a result, draft a report with reddit_feedback_send (action \"draft\"). " +
159
+ "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\".";
160
+
161
+ const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });
162
+
163
+ // Handlers for tools that carry local: "<name>" in the catalog. A name the
164
+ // catalog uses and this map lacks is a boot-time failure, never a silent
165
+ // passthrough to the API with the local args attached.
166
+ const LOCAL_HANDLERS = {
167
+ feedback: createFeedbackHandler({
168
+ callEndpoint,
169
+ version: VERSION,
170
+ getClientInfo: () => server.server.getClientVersion(),
171
+ getLastError: () => lastError,
172
+ }),
173
+ };
114
174
 
115
175
  for (const tool of TOOLS) {
116
176
  const method = tool.method || "GET";
@@ -120,10 +180,27 @@ for (const tool of TOOLS) {
120
180
  destructiveHint: Boolean(tool.destructive),
121
181
  openWorldHint: true,
122
182
  };
183
+ let handler;
184
+ if (tool.local) {
185
+ handler = LOCAL_HANDLERS[tool.local];
186
+ if (!handler) throw new Error(`[reddit-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/index.js has none`);
187
+ } else {
188
+ handler = async (args) => {
189
+ const result = await callEndpoint(tool.path, args, method, tool);
190
+ if (result?.isError && lastError) {
191
+ let resolved = null;
192
+ try { resolved = buildPath(tool.path, args).path; } catch { resolved = null; }
193
+ // Name the tool only when BOTH method and path match the recorded
194
+ // failure; a GET and a POST can share a path.
195
+ if (sameFailure(lastError, resolved, method)) lastError.tool = tool.name;
196
+ }
197
+ return result;
198
+ };
199
+ }
123
200
  server.registerTool(
124
201
  tool.name,
125
202
  { description: tool.description, inputSchema: tool.shape, annotations },
126
- async (args) => callEndpoint(tool.path, args, method, tool),
203
+ handler,
127
204
  );
128
205
  }
129
206
 
package/src/tools.js CHANGED
@@ -21,6 +21,20 @@
21
21
  // a query string (see buildBody below). `filterSpecFields` on a tool declares
22
22
  // which of its args nest under `filter_spec` in that body; everything else in
23
23
  // `shape` stays top-level (id, cadence_s, active, url, kind, ...).
24
+ //
25
+ // LOCAL TOOLS (2026-09-04): a tool carrying `local: "<handler>"` is dispatched
26
+ // by index.js to a handler in this package instead of straight to the API.
27
+ // Args flagged in `localArgs` are consumed locally and never sent. Today the
28
+ // only such tool is reddit_feedback_send, whose draft queue lives on disk in
29
+ // src/feedback.js and whose POST to /feedback happens only on the user's say.
30
+ // index.js refuses to boot if a catalog entry names a handler it lacks, so a
31
+ // `local` tag can never turn into a silent passthrough with the local args
32
+ // attached.
33
+ //
34
+ // PUBLIC MOUNTS: every tool path is under /api/reddit/ EXCEPT the feedback
35
+ // pair, which the API serves un-prefixed (api.redditapis.com/feedback) because
36
+ // it is mounted outside the metered /api surface, like /account. The catalog
37
+ // test pins the allowed prefixes so a typo cannot invent a third one.
24
38
  import { z } from "zod";
25
39
 
26
40
  // Shared Zod input-schema fragments.
@@ -98,6 +112,28 @@ const LISTING_COOKIES = {
98
112
  ),
99
113
  };
100
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
+
101
137
  // ── monitoring filter_spec fragments, shared by monitor_add and monitor_update ──
102
138
  // v1 is SUBREDDIT-SCOPED ONLY (all-Reddit keyword monitoring is not available),
103
139
  // matching apps/api/src/lib/monitor-filter.js's own validation exactly, so a
@@ -158,6 +194,9 @@ const MONITOR_FILTER_FIELDS = {
158
194
  "What to watch in the named subreddits: 'post' (default when omitted), 'comment', or 'both'. Comment monitoring requires a Growth, Pro or Scale plan -- on a lower tier this returns `comment_monitoring_requires_higher_tier` (402). Comments run roughly 7x the volume of posts, so expect proportionally more deliveries and check reddit_monitor_health's delivery ceiling before enabling it on a busy subreddit.",
159
195
  ),
160
196
  min_score: z.number().int().optional().describe("Only match posts with at least this many upvotes."),
197
+ min_relevance: z.number().int().min(0).max(100).optional().describe(
198
+ "AI relevance floor, 0-100. 0 (the default) is off. Above 0, every match is scored by a language model against this monitor's own keywords and anything below the floor is NOT delivered -- it is recorded in your delivery history with status 'suppressed' and reason 'low_relevance', carrying its score and a one-line explanation, so you can always read what was filtered and why. Nothing is silently discarded. Rough calibration: 80-100 squarely on topic, 50-79 related but peripheral, 20-49 tangential, 0-19 the keyword is used in an unrelated sense. The comparison is inclusive, so a score equal to the floor is delivered. If scoring is unavailable the match is delivered UNSCORED rather than withheld. Requires a Growth, Pro or Scale plan -- on a lower tier this returns `ai_relevance_requires_higher_tier` (402). REJECTED with a field-level 400 on a monitor that has no q, include_any or include_all, because there would be no topic to score an item against and the floor could only ever admit everything.",
199
+ ),
161
200
  nsfw: z.boolean().optional().describe("Set false to EXCLUDE NSFW/over-18 posts. Omitted or true both mean NSFW is allowed through -- there is no exclude-by-default; you must explicitly pass false to filter it out. POSTS ONLY: nsfw=false is REJECTED with a field-level 400 when kind is 'comment' or 'both', because Reddit flags NSFW on a post and never on an individual comment, so there is no field to filter a comment on. Run a kind='post' monitor to keep NSFW filtering, and cut unwanted comment text with exclude_terms."),
162
201
  };
163
202
  // Per-monitor webhook targeting (task #66, migration 009). NOT a filter_spec
@@ -194,6 +233,34 @@ export const TOOLS = [
194
233
  ...LIMIT,
195
234
  },
196
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
+ },
197
264
  {
198
265
  name: "reddit_search",
199
266
  path: "/api/reddit/search",
@@ -525,7 +592,7 @@ export const TOOLS = [
525
592
  path: "/api/reddit/monitor/add",
526
593
  method: "POST",
527
594
  write: true,
528
- filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
595
+ filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "min_relevance", "nsfw"],
529
596
  description:
530
597
  "Create a new Reddit monitor: watch one or more subreddits, or ALL of Reddit, for new posts (or comments, via `kind`) matching a filter, and get every match delivered to a webhook you've registered with reddit_monitor_webhook_create. Omit `subreddit` and set `q` for a sitewide keyword monitor covering every subreddit at once (posts only). By default matches go to EVERY active webhook on your account; pass `webhook_ids` to route this monitor's matches to only specific webhook(s). Every redditapis.com account holds a free entitlement of ONE all-of-Reddit post watch at a 60s cadence (up to 10,000 deliveries a day), so no subscription is needed to create that monitor. Naming a `subreddit`, matching comments, a faster cadence and any additional watch require a paid plan. Needs at least one monitor slot free (see reddit_monitor_list's `slots`). Forward-looking only from the moment of creation, or from `baseline_item_id` if given -- it never backfills posts that already existed. Returns the created monitor (with its `id`) on success, or `subscription_required` (402) if the account holds no recognised entitlement at all, `subreddit_scope_requires_paid_plan` (402) if a free account named a `subreddit`, `monitor_slots_exhausted` (402) if the plan's slot limit is reached, `sitewide_slots_exhausted` (402) if the plan's separate sitewide cap is reached, `distinct_subreddit_limit_reached` (402) if the account already watches as many DIFFERENT subreddits as the plan covers (the limit counts distinct subreddits across all your monitors, not monitors, and the same subreddit in two monitors counts once; see reddit_monitor_list's `slots.distinct_subreddits_total`), `sitewide_comment_monitoring_not_available` (501) if a sitewide monitor asks for comments, `subreddit_reserved` (400) if `subreddit` names 'all', `subreddit_not_found` (400) if a named subreddit does not exist, or `webhook_not_found` (400) if a `webhook_ids` entry is not yours.",
531
598
  shape: {
@@ -552,7 +619,7 @@ export const TOOLS = [
552
619
  path: "/api/reddit/monitor/update",
553
620
  method: "POST",
554
621
  write: true,
555
- filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
622
+ filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "min_relevance", "nsfw"],
556
623
  description:
557
624
  "Update an existing monitor: pause/resume it (`active`), change its poll interval (`cadence_s`), switch between posts and comments (`kind`), replace its filter entirely, or re-target which webhook(s) it delivers to (`webhook_ids`). IMPORTANT: if you pass ANY filter field (subreddit, q, kind, domain, etc.), it REPLACES the whole filter, it does not merge with the existing one -- resupply every field you want kept, including `subreddit` AND `kind` (omitting `kind` reverts that monitor to posts-only). Same rule for `webhook_ids`: passing it REPLACES the monitor's targeting outright (an empty array clears back to 'every active webhook'); omitting it entirely leaves the monitor's existing targeting untouched. Omit all filter/webhook_ids fields to change only `active`/`cadence_s`. Returns 404 `monitor_not_found` if the id does not exist or is not yours, or 400 `webhook_not_found` if a `webhook_ids` entry is not yours.",
558
625
  shape: {
@@ -590,7 +657,7 @@ export const TOOLS = [
590
657
  name: "reddit_monitor_deliveries",
591
658
  path: "/api/reddit/monitor/deliveries",
592
659
  description:
593
- "Delivery history: the actual Reddit posts a monitor's webhook has received (or attempted), newest first, including the real post content (title, subreddit, permalink, author). Answers 'what did I actually get sent', not just 'how many' (see reddit_monitor_health for counts). Omit `id` to aggregate history across every monitor you own.",
660
+ "Delivery history: the actual Reddit posts a monitor's webhook has received (or attempted), newest first, including the real post content (title, subreddit, permalink, author). Answers 'what did I actually get sent', not just 'how many' (see reddit_monitor_health for counts). Every delivered item also carries `payload.items[].enrichment`: a `relevance.score` (0-1, how much of THIS monitor's own keyword criteria the item matched -- not a model's confidence), a `sentiment` (`polarity` -1 to 1 plus a positive/negative/mixed/neutral `label`), and an `intent.tag` (question, recommendation_request, complaint, promotion, praise, or discussion). All three are deterministic keyword/lexicon/rule heuristics computed at no extra cost -- each carries its own `method` field and NONE of them is a machine-learning or LLM call, so do not describe a score here as ML-derived. Omit `id` to aggregate history across every monitor you own.",
594
661
  shape: {
595
662
  id: z.string().optional().describe("Narrow to one monitor's history. Omit to aggregate across every monitor you own."),
596
663
  status: z.enum(["pending", "delivered", "failed", "dead", "suppressed"]).optional().describe(
@@ -639,9 +706,125 @@ export const TOOLS = [
639
706
  "Permanently delete a webhook. Any monitor still pointing at it will fail to deliver until repointed at a different webhook -- this does NOT cascade-delete or pause the monitors using it. Cannot be undone. Returns 404 `webhook_not_found` if the id does not exist or is not yours.",
640
707
  shape: { ...WEBHOOK_ID },
641
708
  },
709
+
710
+ // ── feedback: report a defect or a gap to the redditapis.com team, with the
711
+ // user's review between the draft and the send ─────────────────────────
712
+ {
713
+ name: "reddit_feedback_send",
714
+ path: "/feedback",
715
+ method: "POST",
716
+ write: true,
717
+ local: "feedback",
718
+ localArgs: ["action", "ids"],
719
+ description:
720
+ "Report a product problem or gap in redditapis.com to its team from inside this session, the way Claude Code's own feedback tool works: a report is DRAFTED to a local queue first (action \"draft\", the default) and SENT only after the user reviews it. Drafting sends nothing, needs no confirmation, and should not be announced mid-task. WHEN TO DRAFT, only at high-signal moments: a redditapis tool call failed with an error that was not a missing key (401), credits (402) or a rate limit (429), and the user had to work around it; the user asked for something no redditapis tool covers; a documented field came back empty or wrong; the user was clearly frustrated with a result. One draft per distinct issue, never twice for the same one. FORMAT for details, four labelled bullets in this order: 'What happened:' observed vs expected, exact error text if short. 'What the user said:' quoted verbatim, or 'user did not comment'. 'Repro:' the minimal call that reproduces it. 'Evidence:' tool name, endpoint, HTTP status, request id (the last failing call is attached automatically where you leave a gap). Facts only: no guessing, no API keys or secrets, no personal names. REVIEW: when the user asks to see or send feedback, call action \"list\", then action \"send\" with ONLY the draft ids the user named in their own message, or action \"discard\". Sending posts each draft to POST /feedback (free, not metered) and returns a server id that reddit_feedback_get can check later.",
721
+ shape: {
722
+ action: z.enum(["draft", "list", "send", "discard"]).optional().describe(
723
+ "What to do. \"draft\" (default) queues a new report locally and sends nothing. \"list\" shows the pending drafts with their ids. \"send\" posts the drafts named in ids to redditapis.com; use it only for ids the user named. \"discard\" drops the drafts named in ids.",
724
+ ),
725
+ type: z.enum(["bug", "idea", "missing_capability"]).optional().describe(
726
+ "Required for a draft. \"bug\": a tool or endpoint misbehaved. \"idea\": a change that would have made the task easier. \"missing_capability\": the user needed something no tool provides.",
727
+ ),
728
+ title: z.string().max(120).optional().describe(
729
+ "Required for a draft. One specific line, at most 120 characters, naming the tool or endpoint and the defect, e.g. \"reddit_post_comments returns 502 when the post is deleted\".",
730
+ ),
731
+ details: z.string().max(8000).optional().describe(
732
+ "Required for a draft. At most 8000 characters, four labelled bullets in order: What happened, What the user said (verbatim), Repro, Evidence.",
733
+ ),
734
+ area: z.string().max(80).optional().describe(
735
+ "Optional. The endpoint or feature the report is about, e.g. \"posts/comments\" or \"monitoring\". At most 80 characters.",
736
+ ),
737
+ evidence: z.record(z.string(), z.unknown()).optional().describe(
738
+ "Optional identifiers only, never payloads: {tool, endpoint, status, request_id}. Whatever you leave out is filled from the last failing call in this session; mcp_version and client are always attached.",
739
+ ),
740
+ ids: z.array(z.string()).optional().describe(
741
+ "For action \"send\" or \"discard\": the draft ids to act on, exactly as shown by action \"list\" and named by the user.",
742
+ ),
743
+ },
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
+ name: "reddit_feedback_get",
767
+ path: "/feedback/{id}",
768
+ description:
769
+ "Check the status of a feedback report this account sent earlier (the server id returned by reddit_feedback_send action \"send\"): status new, triaged, shipped or declined, the team's response text if any, and updated_at, which moves only when the team acts on it. Free per call. 404 if the id is not on this account.",
770
+ shape: {
771
+ id: z.string().min(1).describe(
772
+ "The server id of a sent report, as returned by reddit_feedback_send action \"send\" (a UUID). Not a local draft id.",
773
+ ),
774
+ },
775
+ },
642
776
  ];
643
777
 
644
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
+
645
828
  export function buildQuery(args) {
646
829
  const qs = new URLSearchParams();
647
830
  for (const [k, v] of Object.entries(args || {})) {
package/server.json DELETED
@@ -1,30 +0,0 @@
1
- {
2
- "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
- "name": "io.github.redditapis/redditapis-mcp",
4
- "description": "Reddit API as MCP tools: search, subreddits, posts, comments, users, monitors, and webhooks.",
5
- "title": "RedditAPIs",
6
- "version": "0.3.2",
7
- "websiteUrl": "https://www.redditapis.com",
8
- "repository": {
9
- "url": "https://github.com/redditapis/redditapis-mcp",
10
- "source": "github"
11
- },
12
- "packages": [
13
- {
14
- "registryType": "npm",
15
- "identifier": "redditapis-mcp",
16
- "version": "0.3.2",
17
- "transport": {
18
- "type": "stdio"
19
- },
20
- "environmentVariables": [
21
- {
22
- "name": "REDDITAPIS_KEY",
23
- "description": "Your redditapis.com API key. Get one at https://www.redditapis.com",
24
- "isRequired": true,
25
- "isSecret": true
26
- }
27
- ]
28
- }
29
- ]
30
- }