redditapis-mcp 0.3.2 → 0.4.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,21 @@
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.4.0 (2026-09-04)
4
+
5
+ ### Added
6
+
7
+ - **`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).
8
+ - **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.
9
+
10
+ ### Fixed
11
+
12
+ - `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.
13
+
14
+ ## 0.3.0 (2026-08-16)
15
+
16
+ ### Added
17
+
18
+ - **`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
19
 
52
20
  ## 0.2.0
53
21
  - **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
 
@@ -172,6 +173,15 @@ v1 monitors are **subreddit-scoped, posts-only** (no all-of-Reddit keyword watch
172
173
  | `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
174
  | `reddit_monitor_webhook_delete` | `POST /api/reddit/monitor/webhook/delete` | Permanently delete a webhook. Does not cascade-pause monitors still pointing at it. |
174
175
 
176
+ ### Feedback: tell the team what broke, after you review the draft
177
+
178
+ 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.
179
+
180
+ | Tool | Endpoint | What it does |
181
+ |------|----------|--------------|
182
+ | `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. |
183
+ | `reddit_feedback_get` | `GET /feedback/{id}` | Read a sent report's status (`new`, `triaged`, `shipped`, `declined`) and the team's response. |
184
+
175
185
  ## Usage examples
176
186
 
177
187
  ### 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.4.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,18 +3,23 @@
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
 
@@ -22,6 +27,7 @@ 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
29
  import { TOOLS, buildQuery, buildPath, buildBody } 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,36 +39,24 @@ 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
59
  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
- };
65
- }
66
60
  const { path, rest } = buildPath(pathTemplate, args);
67
61
  const isWrite = method !== "GET";
68
62
  const q = isWrite ? "" : buildQuery(rest);
@@ -99,18 +93,59 @@ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
99
93
  : res.status >= 500
100
94
  ? " (upstream API error. Retry in a moment; if persistent, contact https://www.redditapis.com)"
101
95
  : "";
102
- return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}` }] };
96
+ lastError = {
97
+ path,
98
+ method,
99
+ status: res.status,
100
+ requestId: res.headers.get("x-request-id") || undefined,
101
+ ts: Date.now(),
102
+ };
103
+ // Credential, credit, rate-limit and not-found failures are the caller's
104
+ // situation (a 404 is almost always a wrong id or a deleted thing, a 409
105
+ // a conflict with the caller's own state), not a product defect;
106
+ // everything else may be one, and the model reads error bodies closely,
107
+ // so the pointer lives here.
108
+ const feedbackHint =
109
+ res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
110
+ ? ""
111
+ : " 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).";
112
+ return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
103
113
  }
114
+ // A success clears the record so a later draft never inherits an old
115
+ // failure's endpoint or request id (review 2026-09-04: a remove's draft
116
+ // carried the previous update's 404).
117
+ lastError = null;
104
118
  return { content: [{ type: "text", text: body }] };
105
119
  } catch (err) {
106
120
  const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
121
+ lastError = { path, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
107
122
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
108
123
  } finally {
109
124
  clearTimeout(timer);
110
125
  }
111
126
  }
112
127
 
113
- const server = new McpServer({ name: "redditapis", version: VERSION });
128
+ // Standing instructions the client hands its model alongside the tool list.
129
+ // This is the trigger list for feedback, in the place a model actually reads.
130
+ const INSTRUCTIONS =
131
+ "redditapis.com MCP server. Read tools cost credits per call (most $0.002); monitor, webhook and feedback tools are free. " +
132
+ "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, " +
133
+ "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\"). " +
134
+ "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\".";
135
+
136
+ const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });
137
+
138
+ // Handlers for tools that carry local: "<name>" in the catalog. A name the
139
+ // catalog uses and this map lacks is a boot-time failure, never a silent
140
+ // passthrough to the API with the local args attached.
141
+ const LOCAL_HANDLERS = {
142
+ feedback: createFeedbackHandler({
143
+ callEndpoint,
144
+ version: VERSION,
145
+ getClientInfo: () => server.server.getClientVersion(),
146
+ getLastError: () => lastError,
147
+ }),
148
+ };
114
149
 
115
150
  for (const tool of TOOLS) {
116
151
  const method = tool.method || "GET";
@@ -120,10 +155,27 @@ for (const tool of TOOLS) {
120
155
  destructiveHint: Boolean(tool.destructive),
121
156
  openWorldHint: true,
122
157
  };
158
+ let handler;
159
+ if (tool.local) {
160
+ handler = LOCAL_HANDLERS[tool.local];
161
+ if (!handler) throw new Error(`[reddit-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/index.js has none`);
162
+ } else {
163
+ handler = async (args) => {
164
+ const result = await callEndpoint(tool.path, args, method, tool);
165
+ if (result?.isError && lastError) {
166
+ let resolved = null;
167
+ try { resolved = buildPath(tool.path, args).path; } catch { resolved = null; }
168
+ // Name the tool only when BOTH method and path match the recorded
169
+ // failure; a GET and a POST can share a path.
170
+ if (sameFailure(lastError, resolved, method)) lastError.tool = tool.name;
171
+ }
172
+ return result;
173
+ };
174
+ }
123
175
  server.registerTool(
124
176
  tool.name,
125
177
  { description: tool.description, inputSchema: tool.shape, annotations },
126
- async (args) => callEndpoint(tool.path, args, method, tool),
178
+ handler,
127
179
  );
128
180
  }
129
181
 
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.
@@ -158,6 +172,9 @@ const MONITOR_FILTER_FIELDS = {
158
172
  "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
173
  ),
160
174
  min_score: z.number().int().optional().describe("Only match posts with at least this many upvotes."),
175
+ min_relevance: z.number().int().min(0).max(100).optional().describe(
176
+ "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.",
177
+ ),
161
178
  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
179
  };
163
180
  // Per-monitor webhook targeting (task #66, migration 009). NOT a filter_spec
@@ -525,7 +542,7 @@ export const TOOLS = [
525
542
  path: "/api/reddit/monitor/add",
526
543
  method: "POST",
527
544
  write: true,
528
- filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
545
+ filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "min_relevance", "nsfw"],
529
546
  description:
530
547
  "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
548
  shape: {
@@ -552,7 +569,7 @@ export const TOOLS = [
552
569
  path: "/api/reddit/monitor/update",
553
570
  method: "POST",
554
571
  write: true,
555
- filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "nsfw"],
572
+ filterSpecFields: ["subreddit", "exclude_subreddits", "kind", "q", "author", "exclude_terms", "domain", "include_any", "include_all", "search_in", "group", "min_score", "min_relevance", "nsfw"],
556
573
  description:
557
574
  "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
575
  shape: {
@@ -590,7 +607,7 @@ export const TOOLS = [
590
607
  name: "reddit_monitor_deliveries",
591
608
  path: "/api/reddit/monitor/deliveries",
592
609
  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.",
610
+ "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
611
  shape: {
595
612
  id: z.string().optional().describe("Narrow to one monitor's history. Omit to aggregate across every monitor you own."),
596
613
  status: z.enum(["pending", "delivered", "failed", "dead", "suppressed"]).optional().describe(
@@ -639,6 +656,53 @@ export const TOOLS = [
639
656
  "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
657
  shape: { ...WEBHOOK_ID },
641
658
  },
659
+
660
+ // ── feedback: report a defect or a gap to the redditapis.com team, with the
661
+ // user's review between the draft and the send ─────────────────────────
662
+ {
663
+ name: "reddit_feedback_send",
664
+ path: "/feedback",
665
+ method: "POST",
666
+ write: true,
667
+ local: "feedback",
668
+ localArgs: ["action", "ids"],
669
+ description:
670
+ "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.",
671
+ shape: {
672
+ action: z.enum(["draft", "list", "send", "discard"]).optional().describe(
673
+ "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.",
674
+ ),
675
+ type: z.enum(["bug", "idea", "missing_capability"]).optional().describe(
676
+ "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.",
677
+ ),
678
+ title: z.string().max(120).optional().describe(
679
+ "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\".",
680
+ ),
681
+ details: z.string().max(8000).optional().describe(
682
+ "Required for a draft. At most 8000 characters, four labelled bullets in order: What happened, What the user said (verbatim), Repro, Evidence.",
683
+ ),
684
+ area: z.string().max(80).optional().describe(
685
+ "Optional. The endpoint or feature the report is about, e.g. \"posts/comments\" or \"monitoring\". At most 80 characters.",
686
+ ),
687
+ evidence: z.record(z.string(), z.unknown()).optional().describe(
688
+ "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.",
689
+ ),
690
+ ids: z.array(z.string()).optional().describe(
691
+ "For action \"send\" or \"discard\": the draft ids to act on, exactly as shown by action \"list\" and named by the user.",
692
+ ),
693
+ },
694
+ },
695
+ {
696
+ name: "reddit_feedback_get",
697
+ path: "/feedback/{id}",
698
+ description:
699
+ "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.",
700
+ shape: {
701
+ id: z.string().min(1).describe(
702
+ "The server id of a sent report, as returned by reddit_feedback_send action \"send\" (a UUID). Not a local draft id.",
703
+ ),
704
+ },
705
+ },
642
706
  ];
643
707
 
644
708
  // Turn tool args into a URL query string. Skips undefined/null/empty values.
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
- }