redditapis-mcp 0.7.0 → 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,22 @@
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
+
3
20
  ## 0.7.0 (2026-09-29)
4
21
 
5
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redditapis-mcp",
3
- "version": "0.7.0",
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,7 +31,7 @@
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 && node test/per-caller-state.test.mjs && node test/paywall.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": {
package/src/server.js CHANGED
@@ -157,6 +157,14 @@ export const INSTRUCTIONS =
157
157
  * (REDDITAPIS_FEEDBACK_DIR); a remote host gives each
158
158
  * caller its own directory
159
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]
160
168
  */
161
169
  export function createServer({
162
170
  apiKey,
@@ -164,6 +172,9 @@ export function createServer({
164
172
  timeoutMs = DEFAULT_TIMEOUT_MS,
165
173
  feedbackEnv = process.env,
166
174
  fetchImpl = fetch,
175
+ authHeaders = null,
176
+ sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
177
+ retryDelaysMs = [3000, 8000],
167
178
  } = {}) {
168
179
  const BASE_URL = String(baseUrl).replace(/\/+$/, "");
169
180
  const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
@@ -185,7 +196,7 @@ export function createServer({
185
196
  // the request would carry an Authorization header reading "Bearer
186
197
  // undefined" and the caller would read a 401 about an INVALID key when the
187
198
  // real answer is that no key was ever set.
188
- if (!apiKey) return paywallResult("no_key");
199
+ if (!apiKey && !authHeaders) return paywallResult("no_key");
189
200
  const { path, rest: pathRest } = buildPath(pathTemplate, args);
190
201
  // A tool that declares `sessionHeaders` has its Reddit session args lifted
191
202
  // out of the query and onto headers. For every other tool this is the
@@ -196,7 +207,7 @@ export function createServer({
196
207
  const url = `${BASE_URL}${path}${q ? `?${q}` : ""}`;
197
208
 
198
209
  const headers = {
199
- Authorization: `Bearer ${apiKey}`,
210
+ ...(authHeaders ? authHeaders : { Authorization: `Bearer ${apiKey}` }),
200
211
  accept: "application/json",
201
212
  "user-agent": `reddit-mcp/${VERSION}`,
202
213
  ...sessionHeaders,
@@ -207,11 +218,34 @@ export function createServer({
207
218
  requestBody = JSON.stringify(buildBody(tool || {}, rest));
208
219
  }
209
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;
210
238
  const ctrl = new AbortController();
211
239
  const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
212
240
  try {
213
241
  const res = await fetchImpl(url, { method, headers, body: requestBody, signal: ctrl.signal });
242
+ gotResponse = true;
214
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
+ }
215
249
  if (!res.ok) {
216
250
  const hint = hintFor(res.status, path);
217
251
  lastError = {
@@ -242,12 +276,21 @@ export function createServer({
242
276
  lastError = null;
243
277
  return { content: [{ type: "text", text: body }] };
244
278
  } catch (err) {
245
- const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(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})` : ""}`;
246
288
  lastError = { path, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
247
289
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
248
290
  } finally {
249
291
  clearTimeout(timer);
250
292
  }
293
+ }
251
294
  }
252
295
 
253
296
  const server = new McpServer({ name: "redditapis", version: VERSION }, { instructions: INSTRUCTIONS });