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 +17 -0
- package/package.json +7 -2
- package/src/server.js +46 -3
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.
|
|
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
|
-
|
|
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 });
|