redditapis-mcp 0.6.0 → 0.7.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,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 (2026-09-29)
4
+
5
+ ### Added
6
+
7
+ - **Agent-actionable paywall.** A missing key, a rejected key (403 Invalid token)
8
+ and an empty balance (402 Insufficient credits) now return a structured payload
9
+ instead of a prose hint:
10
+ `needs` (`account`, `valid_key` or `credits`), the exact page to send the user
11
+ to (`action_url`: signup, API keys, or buy credits), and one sentence the
12
+ agent can relay, both in the text and as `structuredContent`. An agent that
13
+ runs out of credit mid-task can now tell the user where to top up and retry
14
+ the same call. Other failures keep their existing hints, including a 402 for
15
+ a monitoring plan or slot limit, which buying credits would not fix.
16
+ - The MCP SDK floor is pinned to ^1.29.0.
17
+
18
+ ### Changed
19
+
20
+ - **Per-caller server state.** The server is now built by `createServer()` in
21
+ `src/server.js`, which holds the API key and the last-failed-call record in
22
+ its own closure instead of module globals. Under stdio nothing changes (one
23
+ process, one caller); it is the prerequisite for serving the same tools
24
+ remotely, where one process serves many callers. `src/index.js` is now only
25
+ the stdio entry and the one place config is read from the environment.
26
+ `test/per-caller-state.test.mjs` proves two servers in one process never
27
+ share a key or a failure record.
28
+
29
+ ## 0.6.1 (2026-09-11)
30
+
31
+ ### Changed
32
+
33
+ - **The server instructions now ask for a control before an "ignored parameter" report.** A generic query on a score-ordered sort returns the site-wide listing, which reads exactly like a dropped parameter and is not one; a report with that title was sent on 2026-09-11 and needed a correction. The instructions now say: before drafting a report that a parameter is ignored or a field is empty, re-run with a distinctive value that could only match if the parameter was honoured, and with the phrase quoted, then title the report by what the control showed.
34
+ - **`prepublish-tenant-check` refuses a patch bump when the tool catalog grew.** It reads the published tarball from npm (the emitting system, never a recorded count), counts the catalog on both sides, and blocks a publish whose version is a patch over the published one while the catalog is larger. 0.6.0 was first cut as 0.5.4 with a new tool and only a reviewer caught it. Fails closed when npm cannot be read.
35
+
3
36
  ## 0.6.0 (2026-09-11)
4
37
 
5
38
  A minor rather than a patch because a tool was added: anyone pinned to 0.5.x opts in rather than receiving it silently, the same rule the 0.5.0 and 0.4.0 entries below state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "mcpName": "io.github.redditapis/redditapis-mcp",
5
5
  "description": "Official MCP server for redditapis.com. Search Reddit posts, comments, communities, users and media; read subreddits, comment trees, top posts and profiles; create and manage monitors and webhooks. Native tools for Claude, Cursor and any MCP client.",
6
6
  "repository": {
@@ -26,11 +26,11 @@
26
26
  "scripts": {
27
27
  "start": "node src/index.js",
28
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 && node test/scannable-without-key.test.mjs",
29
+ "test": "node test/tools.test.mjs && node test/feedback.test.mjs && node test/index-lasterror.test.mjs && node test/scannable-without-key.test.mjs && node test/per-caller-state.test.mjs && node test/paywall.test.mjs",
30
30
  "prepublishOnly": "node scripts/prepublish-tenant-check.mjs"
31
31
  },
32
32
  "dependencies": {
33
- "@modelcontextprotocol/sdk": "^1.0.0",
33
+ "@modelcontextprotocol/sdk": "^1.29.0",
34
34
  "zod": "^3.23.8 || ^4.0.0"
35
35
  },
36
36
  "keywords": [
package/src/index.js CHANGED
@@ -10,10 +10,12 @@
10
10
  // user has reviewed. Each tool is a thin, typed wrapper over a REST endpoint at
11
11
  // https://api.redditapis.com, except reddit_feedback_send, whose draft queue
12
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.
13
+ // tool catalog lives in ./tools.js; the server itself is built by
14
+ // createServer() in ./server.js, which holds every per-caller value (key, last
15
+ // failure) in its own closure so the same code can serve many callers.
15
16
  //
16
- // Config (env):
17
+ // This file is the stdio entry point and the ONLY place config is read from
18
+ // the environment:
17
19
  // REDDITAPIS_KEY required. Your key from https://www.redditapis.com
18
20
  // (REDDIT_APIS_KEY is accepted as an alias).
19
21
  // REDDITAPIS_BASE_URL optional. Defaults to https://api.redditapis.com
@@ -23,21 +25,16 @@
23
25
  //
24
26
  // Run: npx -y redditapis-mcp@latest (stdio transport)
25
27
 
26
- import { createRequire } from "node:module";
27
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
- import { TOOLS, buildQuery, buildPath, buildBody, buildHeaders } from "./tools.js";
30
- import { createFeedbackHandler, sameFailure } from "./feedback.js";
29
+ import { TOOLS } from "./tools.js";
30
+ import { createServer, hintFor, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS } from "./server.js";
31
+
32
+ // Re-exported so existing importers of the entry point keep working.
33
+ export { hintFor };
31
34
 
32
35
  const API_KEY = process.env.REDDITAPIS_KEY || process.env.REDDIT_APIS_KEY;
33
- const BASE_URL = (
34
- process.env.REDDITAPIS_BASE_URL || "https://api.redditapis.com"
35
- ).replace(/\/+$/, "");
36
- const REQUEST_TIMEOUT_MS = Number(process.env.REDDITAPIS_TIMEOUT_MS || 30000);
37
- // Read from package.json rather than a hand-maintained literal -- this drifted
38
- // silently to "0.1.0" while the published package reached 0.1.12, so every
39
- // User-Agent and MCP server handshake understated its own version for months.
40
- const { version: VERSION } = createRequire(import.meta.url)("../package.json");
36
+ const BASE_URL = process.env.REDDITAPIS_BASE_URL || DEFAULT_BASE_URL;
37
+ const REQUEST_TIMEOUT_MS = Number(process.env.REDDITAPIS_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
41
38
 
42
39
  // WARN AND CONTINUE, NEVER exit. A registry scanner spawns the server and calls
43
40
  // tools/list to enumerate what it offers, and tools/list is the DISCOVERY step
@@ -53,190 +50,25 @@ const { version: VERSION } = createRequire(import.meta.url)("../package.json");
53
50
  // NOT a security relaxation. This is a stdio server: whoever spawns it already
54
51
  // has local execution, so there is no unauthenticated party to expose anything
55
52
  // to. A real tool CALL with no key still fails clearly, at the point of the
56
- // call, exactly as it already does for a WRONG key (see the 401 branch below).
53
+ // call, exactly as it already does for a WRONG key (see the 401 branch in
54
+ // server.js).
57
55
  if (!API_KEY) {
58
56
  console.error(
59
57
  "[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.",
60
58
  );
61
59
  }
62
60
 
63
- // The last tool call that failed, so a feedback draft can carry the endpoint,
64
- // status and request id without the model retyping them. Set in callEndpoint's
65
- // error branch; the tool name is added by the registration wrapper below.
66
- let lastError = null;
67
-
68
- // REST call. Resolves any {param} path placeholders from args, and for GET
69
- // sends the rest as a query string; for POST it sends the rest as a JSON body
70
- // (see buildBody -- `tool` is passed through only to read its
71
- // `filterSpecFields`). Forwards the API key as a Bearer token (redditapis.com
72
- // authenticates via `Authorization: Bearer <key>` only).
73
- // The trailing sentence appended to a failed call, so the model reads an error
74
- // it can act on rather than a bare status.
75
- //
76
- // A 404 HINT HAS TO KNOW WHICH ENDPOINT WAS CALLED. This was a flat status-only
77
- // ternary, so every 404 in the catalog got the listing-shaped sentence, and a
78
- // caller whose FEEDBACK id was not found was told to check their subreddit,
79
- // post id or permalink. None of those are involved. The feedback endpoints are
80
- // the first mounted outside the /api/reddit surface, so they are the first
81
- // place that assumption is plainly wrong; /account/* would have been next.
82
- //
83
- // Only the 404 varies, because it is the only status whose useful advice
84
- // depends on what was being looked up. Everything else is about the KEY, the
85
- // CREDIT balance or OUR service and reads the same on every endpoint.
86
- const NOT_FOUND_HINTS = [
87
- [/^\/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.)"],
88
- [/^\/account/, " (not found. That account resource does not exist for this key.)"],
89
- ];
90
- const DEFAULT_NOT_FOUND_HINT =
91
- " (not found. The subreddit, post id, user, or permalink may be wrong or deleted)";
92
-
93
- export function hintFor(status, path) {
94
- if (status === 401) return " (invalid or missing API key, verify REDDITAPIS_KEY at https://www.redditapis.com)";
95
- if (status === 402) return " (insufficient credits, top up at https://www.redditapis.com)";
96
- if (status === 403) return " (access forbidden. The subreddit/user may be private, banned, or quarantined)";
97
- if (status === 404) {
98
- for (const [re, hint] of NOT_FOUND_HINTS) if (re.test(path || "")) return hint;
99
- return DEFAULT_NOT_FOUND_HINT;
100
- }
101
- if (status === 429) return " (rate limited. Wait a few seconds and retry, or reduce request frequency)";
102
- if (status >= 500) return " (upstream API error. Retry in a moment; if persistent, contact https://www.redditapis.com)";
103
- return "";
104
- }
105
-
106
- async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
107
- // The credential check MOVED HERE from startup, so listing tools needs no key
108
- // and calling one still cannot silently proceed without it. Without this the
109
- // relaxation above would send an Authorization header reading "Bearer
110
- // undefined" and the caller would read a 401 about an INVALID key when the
111
- // real answer is that no key was ever set.
112
- if (!API_KEY) {
113
- return {
114
- isError: true,
115
- content: [{
116
- type: "text",
117
- text: "Missing REDDITAPIS_KEY (no API key is set; get one at https://www.redditapis.com and set it in your MCP client config).",
118
- }],
119
- };
120
- }
121
- const { path, rest: pathRest } = buildPath(pathTemplate, args);
122
- // A tool that declares `sessionHeaders` has its Reddit session args lifted
123
- // out of the query and onto headers. For every other tool this is the
124
- // identity, so the request is byte-for-byte what it was before.
125
- const { headers: sessionHeaders, rest } = buildHeaders(tool, pathRest);
126
- const isWrite = method !== "GET";
127
- const q = isWrite ? "" : buildQuery(rest);
128
- const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
129
-
130
- const headers = {
131
- Authorization: `Bearer ${API_KEY}`,
132
- accept: "application/json",
133
- "user-agent": `reddit-mcp/${VERSION}`,
134
- ...sessionHeaders,
135
- };
136
- let requestBody;
137
- if (isWrite) {
138
- headers["content-type"] = "application/json";
139
- requestBody = JSON.stringify(buildBody(tool || {}, rest));
140
- }
141
-
142
- const ctrl = new AbortController();
143
- const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
144
- try {
145
- const res = await fetch(url, { method, headers, body: requestBody, signal: ctrl.signal });
146
- const body = await res.text();
147
- if (!res.ok) {
148
- const hint = hintFor(res.status, path);
149
- lastError = {
150
- path,
151
- method,
152
- status: res.status,
153
- requestId: res.headers.get("x-request-id") || undefined,
154
- ts: Date.now(),
155
- };
156
- // Credential, credit, rate-limit and not-found failures are the caller's
157
- // situation (a 404 is almost always a wrong id or a deleted thing, a 409
158
- // a conflict with the caller's own state), not a product defect;
159
- // everything else may be one, and the model reads error bodies closely,
160
- // so the pointer lives here.
161
- const feedbackHint =
162
- res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
163
- ? ""
164
- : " 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).";
165
- return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
166
- }
167
- // A success clears the record so a later draft never inherits an old
168
- // failure's endpoint or request id (review 2026-09-04: a remove's draft
169
- // carried the previous update's 404).
170
- lastError = null;
171
- return { content: [{ type: "text", text: body }] };
172
- } catch (err) {
173
- const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
174
- lastError = { path, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
175
- return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
176
- } finally {
177
- clearTimeout(timer);
178
- }
179
- }
180
-
181
- // Standing instructions the client hands its model alongside the tool list.
182
- // This is the trigger list for feedback, in the place a model actually reads.
183
- const INSTRUCTIONS =
184
- "redditapis.com MCP server. Read tools cost credits per call (most $0.002); monitor, webhook and feedback tools are free. " +
185
- "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, " +
186
- "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\"). " +
187
- "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\".";
188
-
189
- const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });
190
-
191
- // Handlers for tools that carry local: "<name>" in the catalog. A name the
192
- // catalog uses and this map lacks is a boot-time failure, never a silent
193
- // passthrough to the API with the local args attached.
194
- const LOCAL_HANDLERS = {
195
- feedback: createFeedbackHandler({
196
- callEndpoint,
197
- version: VERSION,
198
- getClientInfo: () => server.server.getClientVersion(),
199
- getLastError: () => lastError,
200
- }),
201
- };
202
-
203
- for (const tool of TOOLS) {
204
- const method = tool.method || "GET";
205
- const annotations = {
206
- title: tool.name,
207
- readOnlyHint: !tool.write,
208
- destructiveHint: Boolean(tool.destructive),
209
- openWorldHint: true,
210
- };
211
- let handler;
212
- if (tool.local) {
213
- handler = LOCAL_HANDLERS[tool.local];
214
- if (!handler) throw new Error(`[reddit-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/index.js has none`);
215
- } else {
216
- handler = async (args) => {
217
- const result = await callEndpoint(tool.path, args, method, tool);
218
- if (result?.isError && lastError) {
219
- let resolved = null;
220
- try { resolved = buildPath(tool.path, args).path; } catch { resolved = null; }
221
- // Name the tool only when BOTH method and path match the recorded
222
- // failure; a GET and a POST can share a path.
223
- if (sameFailure(lastError, resolved, method)) lastError.tool = tool.name;
224
- }
225
- return result;
226
- };
227
- }
228
- server.registerTool(
229
- tool.name,
230
- { description: tool.description, inputSchema: tool.shape, annotations },
231
- handler,
232
- );
233
- }
234
-
235
61
  async function main() {
62
+ const { server, baseUrl } = createServer({
63
+ apiKey: API_KEY,
64
+ baseUrl: BASE_URL,
65
+ timeoutMs: REQUEST_TIMEOUT_MS,
66
+ feedbackEnv: process.env,
67
+ });
236
68
  const transport = new StdioServerTransport();
237
69
  await server.connect(transport);
238
70
  // Logs go to stderr so they never corrupt the stdio JSON-RPC stream.
239
- console.error(`[reddit-mcp] ready · ${TOOLS.length} tools · base ${BASE_URL}`);
71
+ console.error(`[reddit-mcp] ready · ${TOOLS.length} tools · base ${baseUrl}`);
240
72
  }
241
73
 
242
74
  main().catch((err) => {
package/src/server.js ADDED
@@ -0,0 +1,301 @@
1
+ // createServer(): one McpServer per caller, holding that caller's key and last
2
+ // failure in its own closure.
3
+ //
4
+ // WHY A FACTORY: the key and the last-failed-call record used to be module
5
+ // globals. Under stdio there is one process per user, so that was harmless;
6
+ // served remotely, one process serves many users, and a module global would
7
+ // send one caller's requests with another caller's key and hand one caller's
8
+ // failure (path, request id) to another caller's feedback draft. Every piece
9
+ // of per-caller state now lives inside createServer, and nothing in this file
10
+ // reads process.env: the entry point (index.js for stdio) resolves config and
11
+ // passes it in.
12
+
13
+ import { createRequire } from "node:module";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { TOOLS, buildQuery, buildPath, buildBody, buildHeaders } from "./tools.js";
16
+ import { createFeedbackHandler, sameFailure } from "./feedback.js";
17
+
18
+ // Read from package.json rather than a hand-maintained literal -- this drifted
19
+ // silently to "0.1.0" while the published package reached 0.1.12, so every
20
+ // User-Agent and MCP server handshake understated its own version for months.
21
+ export const { version: VERSION } = createRequire(import.meta.url)("../package.json");
22
+
23
+ export const DEFAULT_BASE_URL = "https://api.redditapis.com";
24
+ export const DEFAULT_TIMEOUT_MS = 30000;
25
+
26
+ // The trailing sentence appended to a failed call, so the model reads an error
27
+ // it can act on rather than a bare status.
28
+ //
29
+ // A 404 HINT HAS TO KNOW WHICH ENDPOINT WAS CALLED. This was a flat status-only
30
+ // ternary, so every 404 in the catalog got the listing-shaped sentence, and a
31
+ // caller whose FEEDBACK id was not found was told to check their subreddit,
32
+ // post id or permalink. None of those are involved. The feedback endpoints are
33
+ // the first mounted outside the /api/reddit surface, so they are the first
34
+ // place that assumption is plainly wrong; /account/* would have been next.
35
+ //
36
+ // Only the 404 varies, because it is the only status whose useful advice
37
+ // depends on what was being looked up. Everything else is about the KEY, the
38
+ // CREDIT balance or OUR service and reads the same on every endpoint.
39
+ const NOT_FOUND_HINTS = [
40
+ [/^\/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.)"],
41
+ [/^\/account/, " (not found. That account resource does not exist for this key.)"],
42
+ ];
43
+ const DEFAULT_NOT_FOUND_HINT =
44
+ " (not found. The subreddit, post id, user, or permalink may be wrong or deleted)";
45
+
46
+ // AGENT-ACTIONABLE PAYWALL. A missing key, a rejected key and an empty balance
47
+ // are the moments a user decides whether to keep using the product, and they
48
+ // happen inside an agent's turn. Prose like "top up at redditapis.com" leaves
49
+ // the agent guessing where to send the user; a structured payload names the
50
+ // exact page, so the agent can say "you have used your free credit, top up
51
+ // here" and resume after. The payload is both appended to the text (every
52
+ // client reads that) and returned as structuredContent (clients that parse it).
53
+ export const SIGNUP_URL = "https://www.redditapis.com/signup?utm_source=mcp&utm_medium=tool_error";
54
+ export const API_KEYS_URL = "https://www.redditapis.com/dashboard/api-keys?utm_source=mcp&utm_medium=tool_error";
55
+ export const TOP_UP_URL = "https://www.redditapis.com/dashboard/buy-credits?utm_source=mcp&utm_medium=tool_error";
56
+
57
+ export function paywallFor(kind) {
58
+ if (kind === "no_key") {
59
+ return {
60
+ needs: "account",
61
+ message:
62
+ "Missing REDDITAPIS_KEY: no API key is set. Sign up free at redditapis.com (new accounts start " +
63
+ "with free credit, no card), copy the key from the dashboard, set REDDITAPIS_KEY in the MCP " +
64
+ "client config, then retry this call.",
65
+ action_url: SIGNUP_URL,
66
+ api_keys_url: API_KEYS_URL,
67
+ retry: "same call, after the key is set",
68
+ };
69
+ }
70
+ if (kind === "bad_key") {
71
+ return {
72
+ needs: "valid_key",
73
+ message:
74
+ "The redditapis.com API key was rejected (invalid, revoked or rotated). Copy a current key from " +
75
+ "the dashboard, set REDDITAPIS_KEY, then retry this call.",
76
+ action_url: API_KEYS_URL,
77
+ retry: "same call, after the key is replaced",
78
+ };
79
+ }
80
+ if (kind === "credits") {
81
+ return {
82
+ needs: "credits",
83
+ message:
84
+ "The redditapis.com account is out of credits. Top up (pay as you go, no subscription), then " +
85
+ "retry this call; nothing was charged for the failed request. reddit_account_me shows the balance.",
86
+ action_url: TOP_UP_URL,
87
+ retry: "same call, after topping up",
88
+ };
89
+ }
90
+ return null;
91
+ }
92
+
93
+ // Which failures ARE a paywall is decided from the API's own response BODY, not
94
+ // the status alone, because the status is shared (review 2026-09-29): the API
95
+ // answers a rejected key with 403 {"error":"Invalid token"} (401 is only a
96
+ // MISSING token, which this client never sends), and 402 also means a monitor
97
+ // plan or slot limit, which buying credits does not fix. Only the two bodies
98
+ // below are paywalls; everything else keeps its ordinary hint.
99
+ export function classifyPaywall(status, bodyText) {
100
+ let body = null;
101
+ try {
102
+ body = JSON.parse(bodyText);
103
+ } catch {
104
+ body = null;
105
+ }
106
+ const err = body && typeof body.error === "string" ? body.error : "";
107
+ if ((status === 403 || status === 401) && err === "Invalid token") return { kind: "bad_key" };
108
+ if (status === 402 && (err === "Insufficient credits" || typeof body?.top_up_url === "string")) {
109
+ const url = typeof body?.top_up_url === "string" && /^https:\/\/www\.redditapis\.com\//.test(body.top_up_url)
110
+ ? body.top_up_url
111
+ : null;
112
+ return { kind: "credits", topUpUrl: url };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function paywallResult(kind, detail = "", overrides = {}) {
118
+ const p = { ...paywallFor(kind), ...overrides };
119
+ return {
120
+ isError: true,
121
+ content: [{ type: "text", text: `${p.message}${detail ? ` (${detail})` : ""}\n\n${JSON.stringify(p)}` }],
122
+ structuredContent: p,
123
+ };
124
+ }
125
+
126
+ export function hintFor(status, path) {
127
+ if (status === 401) return " (invalid or missing API key, verify REDDITAPIS_KEY at https://www.redditapis.com)";
128
+ if (status === 402) return " (insufficient credits, top up at https://www.redditapis.com)";
129
+ if (status === 403) return " (access forbidden. The subreddit/user may be private, banned, or quarantined)";
130
+ if (status === 404) {
131
+ for (const [re, hint] of NOT_FOUND_HINTS) if (re.test(path || "")) return hint;
132
+ return DEFAULT_NOT_FOUND_HINT;
133
+ }
134
+ if (status === 429) return " (rate limited. Wait a few seconds and retry, or reduce request frequency)";
135
+ if (status >= 500) return " (upstream API error. Retry in a moment; if persistent, contact https://www.redditapis.com)";
136
+ return "";
137
+ }
138
+
139
+ // Standing instructions the client hands its model alongside the tool list.
140
+ // This is the trigger list for feedback, in the place a model actually reads.
141
+ export const INSTRUCTIONS =
142
+ "redditapis.com MCP server. Read tools cost credits per call (most $0.002); monitor, webhook and feedback tools are free. " +
143
+ "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, " +
144
+ "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\"). " +
145
+ "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\". " +
146
+ "Before drafting a report that a parameter is IGNORED or a field is EMPTY, re-run the call with a distinctive value that could only match if the parameter was honoured, and with the phrase quoted; " +
147
+ "if either comes back on topic the issue is ranking or matching, so title it that way and say what the control showed.";
148
+
149
+ /**
150
+ * Build one server for one caller.
151
+ *
152
+ * @param {object} opts
153
+ * @param {string|undefined} opts.apiKey the caller's key; calls fail clearly without one
154
+ * @param {string} [opts.baseUrl] API origin, default https://api.redditapis.com
155
+ * @param {number} [opts.timeoutMs] per-request timeout, must be > 0
156
+ * @param {object} [opts.feedbackEnv] env-shaped object for the feedback queue location
157
+ * (REDDITAPIS_FEEDBACK_DIR); a remote host gives each
158
+ * caller its own directory
159
+ * @param {typeof fetch} [opts.fetchImpl] injectable for tests
160
+ */
161
+ export function createServer({
162
+ apiKey,
163
+ baseUrl = DEFAULT_BASE_URL,
164
+ timeoutMs = DEFAULT_TIMEOUT_MS,
165
+ feedbackEnv = process.env,
166
+ fetchImpl = fetch,
167
+ } = {}) {
168
+ const BASE_URL = String(baseUrl).replace(/\/+$/, "");
169
+ const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
170
+
171
+ // The last tool call that failed, so a feedback draft can carry the endpoint,
172
+ // status and request id without the model retyping them. Set in callEndpoint's
173
+ // error branch; the tool name is added by the registration wrapper below.
174
+ // Per server, so one caller's failure never reaches another caller's draft.
175
+ let lastError = null;
176
+
177
+ // REST call. Resolves any {param} path placeholders from args, and for GET
178
+ // sends the rest as a query string; for POST it sends the rest as a JSON body
179
+ // (see buildBody -- `tool` is passed through only to read its
180
+ // `filterSpecFields`). Forwards the API key as a Bearer token (redditapis.com
181
+ // authenticates via `Authorization: Bearer <key>` only).
182
+ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
183
+ // The credential check lives here, not at startup, so listing tools needs no
184
+ // key and calling one still cannot silently proceed without it. Without this
185
+ // the request would carry an Authorization header reading "Bearer
186
+ // undefined" and the caller would read a 401 about an INVALID key when the
187
+ // real answer is that no key was ever set.
188
+ if (!apiKey) return paywallResult("no_key");
189
+ const { path, rest: pathRest } = buildPath(pathTemplate, args);
190
+ // A tool that declares `sessionHeaders` has its Reddit session args lifted
191
+ // out of the query and onto headers. For every other tool this is the
192
+ // identity, so the request is byte-for-byte what it was before.
193
+ const { headers: sessionHeaders, rest } = buildHeaders(tool, pathRest);
194
+ const isWrite = method !== "GET";
195
+ const q = isWrite ? "" : buildQuery(rest);
196
+ const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
197
+
198
+ const headers = {
199
+ Authorization: `Bearer ${apiKey}`,
200
+ accept: "application/json",
201
+ "user-agent": `reddit-mcp/${VERSION}`,
202
+ ...sessionHeaders,
203
+ };
204
+ let requestBody;
205
+ if (isWrite) {
206
+ headers["content-type"] = "application/json";
207
+ requestBody = JSON.stringify(buildBody(tool || {}, rest));
208
+ }
209
+
210
+ const ctrl = new AbortController();
211
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
212
+ try {
213
+ const res = await fetchImpl(url, { method, headers, body: requestBody, signal: ctrl.signal });
214
+ const body = await res.text();
215
+ if (!res.ok) {
216
+ const hint = hintFor(res.status, path);
217
+ lastError = {
218
+ path,
219
+ method,
220
+ status: res.status,
221
+ requestId: res.headers.get("x-request-id") || undefined,
222
+ ts: Date.now(),
223
+ };
224
+ // Credential, credit, rate-limit and not-found failures are the caller's
225
+ // situation (a 404 is almost always a wrong id or a deleted thing, a 409
226
+ // a conflict with the caller's own state), not a product defect;
227
+ // everything else may be one, and the model reads error bodies closely,
228
+ // so the pointer lives here.
229
+ const feedbackHint =
230
+ res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
231
+ ? ""
232
+ : " 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).";
233
+ const pw = classifyPaywall(res.status, body);
234
+ if (pw) {
235
+ return paywallResult(pw.kind, `HTTP ${res.status}: ${body.slice(0, 1200)}`, pw.topUpUrl ? { action_url: pw.topUpUrl } : {});
236
+ }
237
+ return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
238
+ }
239
+ // A success clears the record so a later draft never inherits an old
240
+ // failure's endpoint or request id (review 2026-09-04: a remove's draft
241
+ // carried the previous update's 404).
242
+ lastError = null;
243
+ return { content: [{ type: "text", text: body }] };
244
+ } catch (err) {
245
+ const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
246
+ lastError = { path, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
247
+ return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
248
+ } finally {
249
+ clearTimeout(timer);
250
+ }
251
+ }
252
+
253
+ const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });
254
+
255
+ // Handlers for tools that carry local: "<name>" in the catalog. A name the
256
+ // catalog uses and this map lacks is a boot-time failure, never a silent
257
+ // passthrough to the API with the local args attached.
258
+ const LOCAL_HANDLERS = {
259
+ feedback: createFeedbackHandler({
260
+ callEndpoint,
261
+ version: VERSION,
262
+ getClientInfo: () => server.server.getClientVersion(),
263
+ getLastError: () => lastError,
264
+ env: feedbackEnv,
265
+ }),
266
+ };
267
+
268
+ for (const tool of TOOLS) {
269
+ const method = tool.method || "GET";
270
+ const annotations = {
271
+ title: tool.name,
272
+ readOnlyHint: !tool.write,
273
+ destructiveHint: Boolean(tool.destructive),
274
+ openWorldHint: true,
275
+ };
276
+ let handler;
277
+ if (tool.local) {
278
+ handler = LOCAL_HANDLERS[tool.local];
279
+ if (!handler) throw new Error(`[reddit-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/server.js has none`);
280
+ } else {
281
+ handler = async (args) => {
282
+ const result = await callEndpoint(tool.path, args, method, tool);
283
+ if (result?.isError && lastError) {
284
+ let resolved = null;
285
+ try { resolved = buildPath(tool.path, args).path; } catch { resolved = null; }
286
+ // Name the tool only when BOTH method and path match the recorded
287
+ // failure; a GET and a POST can share a path.
288
+ if (sameFailure(lastError, resolved, method)) lastError.tool = tool.name;
289
+ }
290
+ return result;
291
+ };
292
+ }
293
+ server.registerTool(
294
+ tool.name,
295
+ { description: tool.description, inputSchema: tool.shape, annotations },
296
+ handler,
297
+ );
298
+ }
299
+
300
+ return { server, callEndpoint, getLastError: () => lastError, baseUrl: BASE_URL, timeoutMs: REQUEST_TIMEOUT_MS };
301
+ }