@twitterapis/mcp 0.16.0 → 0.16.1
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 +12 -0
- package/package.json +1 -1
- package/src/server.js +31 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.1 (2026-09-29)
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **A restart answered in JSON is ridden out too.** The gateway now answers a
|
|
8
|
+
restart with a JSON 503 (`"error": "gateway_restarting"`, `Retry-After`)
|
|
9
|
+
instead of its HTML page; a read retries on either. Any other JSON 503 from
|
|
10
|
+
the API, and that code on any other status, is still never retried.
|
|
11
|
+
- **A cancelled call stops.** When the MCP client cancels a tool call, the
|
|
12
|
+
request in flight is aborted and the wait before a retry ends at once; the
|
|
13
|
+
result says "Request cancelled by the caller." instead of reporting a timeout.
|
|
14
|
+
|
|
3
15
|
## 0.16.0 (2026-09-29)
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twitterapis/mcp",
|
|
3
3
|
"mcpName": "io.github.TwitterAPIs/twitterapis-mcp",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.1",
|
|
5
5
|
"description": "Official MCP server for twitterapis.com, the Twitter/X API (search, users, followers, tweets, threads, lists, likes, bookmarks, DMs) plus write actions (post/like/retweet/follow) as native tools for Claude, Cursor, and any MCP client.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/server.js
CHANGED
|
@@ -204,7 +204,7 @@ export function createServer({
|
|
|
204
204
|
// substitute into the URL template) and callEndpoint splices them into path
|
|
205
205
|
// before building the query string or body, so a pathParams arg never leaks
|
|
206
206
|
// into either.
|
|
207
|
-
async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
|
|
207
|
+
async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = [], { signal: callerSignal } = {}) {
|
|
208
208
|
if (!apiKey && !authHeaders) return paywallResult("no_key");
|
|
209
209
|
// Fill {name} URL segments from args and strip those keys, so a pathParams arg
|
|
210
210
|
// (e.g. a monitor/webhook id) never also leaks into the query string or JSON
|
|
@@ -272,16 +272,34 @@ export function createServer({
|
|
|
272
272
|
// socket dropped mid-answer can all happen after the API did the work, and
|
|
273
273
|
// "fetch failed" alone also covers DNS and TLS errors that retrying cannot fix.
|
|
274
274
|
const RETRYABLE_NET = new Set(["ECONNREFUSED"]);
|
|
275
|
-
|
|
276
|
-
|
|
275
|
+
// The gateway answers a restart two ways: nginx's stock HTML 502/503 page,
|
|
276
|
+
// or (since 2026-09-29) its own JSON 503 whose error is "gateway_restarting".
|
|
277
|
+
// Nginx sends that JSON only when it could not reach the API at all, so the
|
|
278
|
+
// API's own JSON errors (which never use that code) are still never retried.
|
|
279
|
+
const gatewayFailure = (status, text) => {
|
|
280
|
+
if ((status === 502 || status === 503) && /^\s*<(!doctype|html)/i.test(text)) return true;
|
|
281
|
+
if (status !== 503) return false;
|
|
282
|
+
try { return JSON.parse(text)?.error === "gateway_restarting"; } catch { return false; }
|
|
283
|
+
};
|
|
284
|
+
// The MCP caller's cancel (notifications/cancelled) aborts the request in
|
|
285
|
+
// flight and any wait between retries, so a cancelled call stops at once.
|
|
286
|
+
const cancelled = () => Boolean(callerSignal?.aborted);
|
|
287
|
+
const pause = (ms) => cancelled() ? Promise.resolve() : new Promise((resolve) => {
|
|
288
|
+
const done = () => { callerSignal?.removeEventListener?.("abort", done); resolve(); };
|
|
289
|
+
Promise.resolve(sleepImpl(ms)).then(done, done);
|
|
290
|
+
callerSignal?.addEventListener?.("abort", done, { once: true });
|
|
291
|
+
});
|
|
277
292
|
const transientNetwork = (err) =>
|
|
278
293
|
!gotResponse && err?.name !== "AbortError" && RETRYABLE_NET.has(err?.cause?.code ?? err?.code);
|
|
279
294
|
let attempt = 0;
|
|
280
295
|
let gotResponse = false;
|
|
281
296
|
for (;;) {
|
|
282
297
|
gotResponse = false;
|
|
298
|
+
if (cancelled()) return { isError: true, content: [{ type: "text", text: "Request cancelled by the caller." }] };
|
|
283
299
|
const ctrl = new AbortController();
|
|
284
300
|
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
301
|
+
const onCancel = () => ctrl.abort();
|
|
302
|
+
callerSignal?.addEventListener?.("abort", onCancel, { once: true });
|
|
285
303
|
try {
|
|
286
304
|
const res = await fetchImpl(url, {
|
|
287
305
|
method,
|
|
@@ -291,9 +309,10 @@ export function createServer({
|
|
|
291
309
|
});
|
|
292
310
|
gotResponse = true;
|
|
293
311
|
const body = await res.text();
|
|
294
|
-
if (canRetry && attempt < retryDelaysMs.length && gatewayFailure(res.status, body)) {
|
|
312
|
+
if (canRetry && !cancelled() && attempt < retryDelaysMs.length && gatewayFailure(res.status, body)) {
|
|
295
313
|
clearTimeout(timer);
|
|
296
|
-
|
|
314
|
+
callerSignal?.removeEventListener?.("abort", onCancel);
|
|
315
|
+
await pause(retryDelaysMs[attempt++]);
|
|
297
316
|
continue;
|
|
298
317
|
}
|
|
299
318
|
if (!res.ok) {
|
|
@@ -323,11 +342,13 @@ export function createServer({
|
|
|
323
342
|
lastError = null;
|
|
324
343
|
return { content: [{ type: "text", text: body }] };
|
|
325
344
|
} catch (err) {
|
|
326
|
-
if (canRetry && attempt < retryDelaysMs.length && transientNetwork(err)) {
|
|
345
|
+
if (canRetry && !cancelled() && attempt < retryDelaysMs.length && transientNetwork(err)) {
|
|
327
346
|
clearTimeout(timer);
|
|
328
|
-
|
|
347
|
+
callerSignal?.removeEventListener?.("abort", onCancel);
|
|
348
|
+
await pause(retryDelaysMs[attempt++]);
|
|
329
349
|
continue;
|
|
330
350
|
}
|
|
351
|
+
if (cancelled()) return { isError: true, content: [{ type: "text", text: "Request cancelled by the caller." }] };
|
|
331
352
|
const code = err?.cause?.code ?? err?.code;
|
|
332
353
|
const msg = err?.name === "AbortError"
|
|
333
354
|
? `timed out after ${REQUEST_TIMEOUT_MS}ms`
|
|
@@ -336,6 +357,7 @@ export function createServer({
|
|
|
336
357
|
return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
|
|
337
358
|
} finally {
|
|
338
359
|
clearTimeout(timer);
|
|
360
|
+
callerSignal?.removeEventListener?.("abort", onCancel);
|
|
339
361
|
}
|
|
340
362
|
}
|
|
341
363
|
}
|
|
@@ -370,8 +392,8 @@ export function createServer({
|
|
|
370
392
|
handler = LOCAL_HANDLERS[tool.local];
|
|
371
393
|
if (!handler) throw new Error(`[twitterapis-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/server.js has none`);
|
|
372
394
|
} else {
|
|
373
|
-
handler = async (args) => {
|
|
374
|
-
const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []);
|
|
395
|
+
handler = async (args, extra) => {
|
|
396
|
+
const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || [], { signal: extra?.signal });
|
|
375
397
|
if (result?.isError && lastError) {
|
|
376
398
|
let resolved = null;
|
|
377
399
|
try { resolved = resolvePathParams(tool.path, tool.pathParams || [], args).path; } catch { resolved = null; }
|