@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 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.0",
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
- const gatewayFailure = (status, text) =>
276
- (status === 502 || status === 503) && /^\s*<(!doctype|html)/i.test(text);
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
- await sleepImpl(retryDelaysMs[attempt++]);
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
- await sleepImpl(retryDelaysMs[attempt++]);
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; }