redditapis-mcp 0.6.1 → 0.8.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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-09-29)
4
+
5
+ ### Added
6
+
7
+ - **Remote hosting.** `redditapis-mcp/server` exports `createServer`, and an
8
+ `authHeaders` option authenticates calls with headers the host supplies
9
+ instead of an API key, for a remote MCP endpoint that has already signed the
10
+ caller in (a connected app over OAuth).
11
+
12
+ ### Changed
13
+
14
+ - **A read rides out an API restart.** A read that gets the gateway's HTML
15
+ 502/503, or a refused connection, is retried after 3 and then 8
16
+ seconds. The API's own JSON errors, gateway timeouts, DNS or TLS failures
17
+ and every write are never retried, so a request the API may already have
18
+ handled is not sent twice.
19
+
20
+ ## 0.7.0 (2026-09-29)
21
+
22
+ ### Added
23
+
24
+ - **Agent-actionable paywall.** A missing key, a rejected key (403 Invalid token)
25
+ and an empty balance (402 Insufficient credits) now return a structured payload
26
+ instead of a prose hint:
27
+ `needs` (`account`, `valid_key` or `credits`), the exact page to send the user
28
+ to (`action_url`: signup, API keys, or buy credits), and one sentence the
29
+ agent can relay, both in the text and as `structuredContent`. An agent that
30
+ runs out of credit mid-task can now tell the user where to top up and retry
31
+ the same call. Other failures keep their existing hints, including a 402 for
32
+ a monitoring plan or slot limit, which buying credits would not fix.
33
+ - The MCP SDK floor is pinned to ^1.29.0.
34
+
35
+ ### Changed
36
+
37
+ - **Per-caller server state.** The server is now built by `createServer()` in
38
+ `src/server.js`, which holds the API key and the last-failed-call record in
39
+ its own closure instead of module globals. Under stdio nothing changes (one
40
+ process, one caller); it is the prerequisite for serving the same tools
41
+ remotely, where one process serves many callers. `src/index.js` is now only
42
+ the stdio entry and the one place config is read from the environment.
43
+ `test/per-caller-state.test.mjs` proves two servers in one process never
44
+ share a key or a failure record.
45
+
3
46
  ## 0.6.1 (2026-09-11)
4
47
 
5
48
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.6.1",
3
+ "version": "0.8.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": {
@@ -13,6 +13,11 @@
13
13
  "redditapis-mcp": "src/index.js"
14
14
  },
15
15
  "main": "src/index.js",
16
+ "exports": {
17
+ ".": "./src/index.js",
18
+ "./server": "./src/server.js",
19
+ "./package.json": "./package.json"
20
+ },
16
21
  "files": [
17
22
  "src",
18
23
  "README.md",
@@ -26,11 +31,11 @@
26
31
  "scripts": {
27
32
  "start": "node src/index.js",
28
33
  "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",
34
+ "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 && node test/remote-host.test.mjs",
30
35
  "prepublishOnly": "node scripts/prepublish-tenant-check.mjs"
31
36
  },
32
37
  "dependencies": {
33
- "@modelcontextprotocol/sdk": "^1.0.0",
38
+ "@modelcontextprotocol/sdk": "^1.29.0",
34
39
  "zod": "^3.23.8 || ^4.0.0"
35
40
  },
36
41
  "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,192 +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
- "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; " +
189
- "if either comes back on topic the issue is ranking or matching, so title it that way and say what the control showed.";
190
-
191
- const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });
192
-
193
- // Handlers for tools that carry local: "<name>" in the catalog. A name the
194
- // catalog uses and this map lacks is a boot-time failure, never a silent
195
- // passthrough to the API with the local args attached.
196
- const LOCAL_HANDLERS = {
197
- feedback: createFeedbackHandler({
198
- callEndpoint,
199
- version: VERSION,
200
- getClientInfo: () => server.server.getClientVersion(),
201
- getLastError: () => lastError,
202
- }),
203
- };
204
-
205
- for (const tool of TOOLS) {
206
- const method = tool.method || "GET";
207
- const annotations = {
208
- title: tool.name,
209
- readOnlyHint: !tool.write,
210
- destructiveHint: Boolean(tool.destructive),
211
- openWorldHint: true,
212
- };
213
- let handler;
214
- if (tool.local) {
215
- handler = LOCAL_HANDLERS[tool.local];
216
- if (!handler) throw new Error(`[reddit-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/index.js has none`);
217
- } else {
218
- handler = async (args) => {
219
- const result = await callEndpoint(tool.path, args, method, tool);
220
- if (result?.isError && lastError) {
221
- let resolved = null;
222
- try { resolved = buildPath(tool.path, args).path; } catch { resolved = null; }
223
- // Name the tool only when BOTH method and path match the recorded
224
- // failure; a GET and a POST can share a path.
225
- if (sameFailure(lastError, resolved, method)) lastError.tool = tool.name;
226
- }
227
- return result;
228
- };
229
- }
230
- server.registerTool(
231
- tool.name,
232
- { description: tool.description, inputSchema: tool.shape, annotations },
233
- handler,
234
- );
235
- }
236
-
237
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
+ });
238
68
  const transport = new StdioServerTransport();
239
69
  await server.connect(transport);
240
70
  // Logs go to stderr so they never corrupt the stdio JSON-RPC stream.
241
- console.error(`[reddit-mcp] ready · ${TOOLS.length} tools · base ${BASE_URL}`);
71
+ console.error(`[reddit-mcp] ready · ${TOOLS.length} tools · base ${baseUrl}`);
242
72
  }
243
73
 
244
74
  main().catch((err) => {
package/src/server.js ADDED
@@ -0,0 +1,344 @@
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
+ * @param {Record<string,string>} [opts.authHeaders]
161
+ * headers that authenticate each call INSTEAD of the API key, for a host
162
+ * that already authenticated the caller another way (a connected app's
163
+ * OAuth token resolved to an account) and forwards calls over its own
164
+ * trusted channel. When set, no API key is required or sent.
165
+ * @param {(ms:number)=>Promise<void>} [opts.sleepImpl] injectable for tests
166
+ * @param {number[]} [opts.retryDelaysMs] waits before each retry of a read that
167
+ * hit a gateway failure (an API restart); default [3000, 8000]
168
+ */
169
+ export function createServer({
170
+ apiKey,
171
+ baseUrl = DEFAULT_BASE_URL,
172
+ timeoutMs = DEFAULT_TIMEOUT_MS,
173
+ feedbackEnv = process.env,
174
+ fetchImpl = fetch,
175
+ authHeaders = null,
176
+ sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
177
+ retryDelaysMs = [3000, 8000],
178
+ } = {}) {
179
+ const BASE_URL = String(baseUrl).replace(/\/+$/, "");
180
+ const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
181
+
182
+ // The last tool call that failed, so a feedback draft can carry the endpoint,
183
+ // status and request id without the model retyping them. Set in callEndpoint's
184
+ // error branch; the tool name is added by the registration wrapper below.
185
+ // Per server, so one caller's failure never reaches another caller's draft.
186
+ let lastError = null;
187
+
188
+ // REST call. Resolves any {param} path placeholders from args, and for GET
189
+ // sends the rest as a query string; for POST it sends the rest as a JSON body
190
+ // (see buildBody -- `tool` is passed through only to read its
191
+ // `filterSpecFields`). Forwards the API key as a Bearer token (redditapis.com
192
+ // authenticates via `Authorization: Bearer <key>` only).
193
+ async function callEndpoint(pathTemplate, args, method = "GET", tool = null) {
194
+ // The credential check lives here, not at startup, so listing tools needs no
195
+ // key and calling one still cannot silently proceed without it. Without this
196
+ // the request would carry an Authorization header reading "Bearer
197
+ // undefined" and the caller would read a 401 about an INVALID key when the
198
+ // real answer is that no key was ever set.
199
+ if (!apiKey && !authHeaders) return paywallResult("no_key");
200
+ const { path, rest: pathRest } = buildPath(pathTemplate, args);
201
+ // A tool that declares `sessionHeaders` has its Reddit session args lifted
202
+ // out of the query and onto headers. For every other tool this is the
203
+ // identity, so the request is byte-for-byte what it was before.
204
+ const { headers: sessionHeaders, rest } = buildHeaders(tool, pathRest);
205
+ const isWrite = method !== "GET";
206
+ const q = isWrite ? "" : buildQuery(rest);
207
+ const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
208
+
209
+ const headers = {
210
+ ...(authHeaders ? authHeaders : { Authorization: `Bearer ${apiKey}` }),
211
+ accept: "application/json",
212
+ "user-agent": `reddit-mcp/${VERSION}`,
213
+ ...sessionHeaders,
214
+ };
215
+ let requestBody;
216
+ if (isWrite) {
217
+ headers["content-type"] = "application/json";
218
+ requestBody = JSON.stringify(buildBody(tool || {}, rest));
219
+ }
220
+
221
+ // An API restart makes the gateway answer an HTML 502/503 or refuse the
222
+ // connection for a short window. A READ is retried through it; the API's own
223
+ // JSON errors, timeouts and writes never are, so a request the API may already have
224
+ // handled is never sent twice (a gateway 504 is not retried for that reason).
225
+ // Only a REFUSED connection is retried: the request never reached the API,
226
+ // so it cannot have been handled or billed. A reset, a broken pipe or a
227
+ // socket dropped mid-answer can all happen after the API did the work, and
228
+ // "fetch failed" alone also covers DNS and TLS errors that retrying cannot fix.
229
+ const RETRYABLE_NET = new Set(["ECONNREFUSED"]);
230
+ const gatewayFailure = (status, text) =>
231
+ (status === 502 || status === 503) && /^\s*<(!doctype|html)/i.test(text);
232
+ const transientNetwork = (err) =>
233
+ !gotResponse && err?.name !== "AbortError" && RETRYABLE_NET.has(err?.cause?.code ?? err?.code);
234
+ let attempt = 0;
235
+ let gotResponse = false;
236
+ for (;;) {
237
+ gotResponse = false;
238
+ const ctrl = new AbortController();
239
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
240
+ try {
241
+ const res = await fetchImpl(url, { method, headers, body: requestBody, signal: ctrl.signal });
242
+ gotResponse = true;
243
+ const body = await res.text();
244
+ if (!isWrite && attempt < retryDelaysMs.length && gatewayFailure(res.status, body)) {
245
+ clearTimeout(timer);
246
+ await sleepImpl(retryDelaysMs[attempt++]);
247
+ continue;
248
+ }
249
+ if (!res.ok) {
250
+ const hint = hintFor(res.status, path);
251
+ lastError = {
252
+ path,
253
+ method,
254
+ status: res.status,
255
+ requestId: res.headers.get("x-request-id") || undefined,
256
+ ts: Date.now(),
257
+ };
258
+ // Credential, credit, rate-limit and not-found failures are the caller's
259
+ // situation (a 404 is almost always a wrong id or a deleted thing, a 409
260
+ // a conflict with the caller's own state), not a product defect;
261
+ // everything else may be one, and the model reads error bodies closely,
262
+ // so the pointer lives here.
263
+ const feedbackHint =
264
+ res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
265
+ ? ""
266
+ : " 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).";
267
+ const pw = classifyPaywall(res.status, body);
268
+ if (pw) {
269
+ return paywallResult(pw.kind, `HTTP ${res.status}: ${body.slice(0, 1200)}`, pw.topUpUrl ? { action_url: pw.topUpUrl } : {});
270
+ }
271
+ return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
272
+ }
273
+ // A success clears the record so a later draft never inherits an old
274
+ // failure's endpoint or request id (review 2026-09-04: a remove's draft
275
+ // carried the previous update's 404).
276
+ lastError = null;
277
+ return { content: [{ type: "text", text: body }] };
278
+ } catch (err) {
279
+ if (!isWrite && attempt < retryDelaysMs.length && transientNetwork(err)) {
280
+ clearTimeout(timer);
281
+ await sleepImpl(retryDelaysMs[attempt++]);
282
+ continue;
283
+ }
284
+ const code = err?.cause?.code ?? err?.code;
285
+ const msg = err?.name === "AbortError"
286
+ ? `timed out after ${REQUEST_TIMEOUT_MS}ms`
287
+ : `${err?.message || String(err)}${code ? ` (${code})` : ""}`;
288
+ lastError = { path, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
289
+ return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
290
+ } finally {
291
+ clearTimeout(timer);
292
+ }
293
+ }
294
+ }
295
+
296
+ const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });
297
+
298
+ // Handlers for tools that carry local: "<name>" in the catalog. A name the
299
+ // catalog uses and this map lacks is a boot-time failure, never a silent
300
+ // passthrough to the API with the local args attached.
301
+ const LOCAL_HANDLERS = {
302
+ feedback: createFeedbackHandler({
303
+ callEndpoint,
304
+ version: VERSION,
305
+ getClientInfo: () => server.server.getClientVersion(),
306
+ getLastError: () => lastError,
307
+ env: feedbackEnv,
308
+ }),
309
+ };
310
+
311
+ for (const tool of TOOLS) {
312
+ const method = tool.method || "GET";
313
+ const annotations = {
314
+ title: tool.name,
315
+ readOnlyHint: !tool.write,
316
+ destructiveHint: Boolean(tool.destructive),
317
+ openWorldHint: true,
318
+ };
319
+ let handler;
320
+ if (tool.local) {
321
+ handler = LOCAL_HANDLERS[tool.local];
322
+ if (!handler) throw new Error(`[reddit-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/server.js has none`);
323
+ } else {
324
+ handler = async (args) => {
325
+ const result = await callEndpoint(tool.path, args, method, tool);
326
+ if (result?.isError && lastError) {
327
+ let resolved = null;
328
+ try { resolved = buildPath(tool.path, args).path; } catch { resolved = null; }
329
+ // Name the tool only when BOTH method and path match the recorded
330
+ // failure; a GET and a POST can share a path.
331
+ if (sameFailure(lastError, resolved, method)) lastError.tool = tool.name;
332
+ }
333
+ return result;
334
+ };
335
+ }
336
+ server.registerTool(
337
+ tool.name,
338
+ { description: tool.description, inputSchema: tool.shape, annotations },
339
+ handler,
340
+ );
341
+ }
342
+
343
+ return { server, callEndpoint, getLastError: () => lastError, baseUrl: BASE_URL, timeoutMs: REQUEST_TIMEOUT_MS };
344
+ }