@twitterapis/mcp 0.15.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,31 @@
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
+
15
+ ## 0.16.0 (2026-09-29)
16
+
17
+ ### Changed
18
+
19
+ - **A read rides out an API restart.** A read (GET) that gets the gateway's
20
+ HTML 502/503, or a refused connection, is retried after 3 and
21
+ then 8 seconds, so a deploy restart no longer surfaces as a Bad Gateway error.
22
+ The API's own JSON errors, gateway timeouts, DNS or TLS failures
23
+ and every write are never retried, so a request the API may already have
24
+ handled is not sent twice.
25
+ - `twitter_tweet_quotes` explains the Top fallback: an empty first Top page is
26
+ served from Latest (`product_used`, `top_fallback`) and its `next_cursor`
27
+ keeps paging that list.
28
+
3
29
  ## 0.15.0 (2026-09-29)
4
30
 
5
31
  ### Added
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.15.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",
@@ -33,7 +33,7 @@
33
33
  "build": "node scripts/gen-tools.mjs --write && node scripts/gen-manifest-tools.mjs --write",
34
34
  "build:check": "node scripts/gen-tools.mjs --check",
35
35
  "openapi:refresh": "node scripts/openapi-refresh.mjs",
36
- "test": "node scripts/gen-tools.mjs --check && node scripts/gen-manifest-tools.mjs --check && node test/gen-tools-endpoints.mjs && node test/catalog-identity.mjs && node test/tools.test.mjs && node test/feedback.test.mjs && node test/hint-for.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/snapshot-redaction.mjs && node test/per-caller-state.test.mjs && node test/body-mode-parity.mjs && node test/readme-parity.mjs && node test/firewall.mjs && node test/registry-manifests.mjs && node scripts/__tests__/reconcile-mcp-publish-chain.test.mjs && node test/paywall.test.mjs",
36
+ "test": "node scripts/gen-tools.mjs --check && node scripts/gen-manifest-tools.mjs --check && node test/gen-tools-endpoints.mjs && node test/catalog-identity.mjs && node test/tools.test.mjs && node test/feedback.test.mjs && node test/hint-for.test.mjs && node test/smoke.mjs && node test/openapi-parity.mjs && node test/snapshot-redaction.mjs && node test/per-caller-state.test.mjs && node test/body-mode-parity.mjs && node test/readme-parity.mjs && node test/firewall.mjs && node test/registry-manifests.mjs && node scripts/__tests__/reconcile-mcp-publish-chain.test.mjs && node test/paywall.test.mjs && node test/retry.test.mjs",
37
37
  "prepublishOnly": "npm test && node test/publish-provenance.mjs && node scripts/prepublish-version-class.mjs",
38
38
  "check:openapi-parity": "node test/openapi-parity.mjs",
39
39
  "check:body-mode-parity": "node test/body-mode-parity.mjs",
package/src/server.js CHANGED
@@ -165,6 +165,9 @@ export const INSTRUCTIONS =
165
165
  * (TWITTERAPIS_FEEDBACK_DIR); a remote host gives each
166
166
  * caller its own directory
167
167
  * @param {typeof fetch} [opts.fetchImpl] injectable for tests
168
+ * @param {(ms:number)=>Promise<void>} [opts.sleepImpl] injectable for tests
169
+ * @param {number[]} [opts.retryDelaysMs] waits before each retry of a read that
170
+ * hit a gateway failure (deploy restart); default [3000, 8000]
168
171
  * @param {Record<string,string>} [opts.authHeaders]
169
172
  * headers that authenticate each call INSTEAD of the API key. For a host
170
173
  * that has already authenticated the caller some other way (an OAuth
@@ -178,6 +181,8 @@ export function createServer({
178
181
  feedbackEnv = process.env,
179
182
  fetchImpl = fetch,
180
183
  authHeaders = null,
184
+ sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
185
+ retryDelaysMs = [3000, 8000],
181
186
  } = {}) {
182
187
  const BASE_URL = String(baseUrl).replace(/\/+$/, "");
183
188
  const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
@@ -199,7 +204,7 @@ export function createServer({
199
204
  // substitute into the URL template) and callEndpoint splices them into path
200
205
  // before building the query string or body, so a pathParams arg never leaks
201
206
  // into either.
202
- async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
207
+ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = [], { signal: callerSignal } = {}) {
203
208
  if (!apiKey && !authHeaders) return paywallResult("no_key");
204
209
  // Fill {name} URL segments from args and strip those keys, so a pathParams arg
205
210
  // (e.g. a monitor/webhook id) never also leaks into the query string or JSON
@@ -255,8 +260,46 @@ export function createServer({
255
260
  }
256
261
  }
257
262
 
263
+ // A DEPLOY RESTART IS NOT AN ERROR THE USER SHOULD SEE. While the API
264
+ // restarts (about 30 to 60 seconds, measured 2026-09-29) the gateway answers
265
+ // an HTML 502/503 or refuses the connection. A READ is retried through
266
+ // that window; the API's own JSON errors, timeouts and writes never are, so
267
+ // a request the API may already have handled is never sent twice (a gateway
268
+ // 504 is not retried for that reason).
269
+ const canRetry = method === "GET";
270
+ // Only a REFUSED connection is retried: the request never reached the API,
271
+ // so it cannot have been handled or billed. A reset, a broken pipe or a
272
+ // socket dropped mid-answer can all happen after the API did the work, and
273
+ // "fetch failed" alone also covers DNS and TLS errors that retrying cannot fix.
274
+ const RETRYABLE_NET = new Set(["ECONNREFUSED"]);
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
+ });
292
+ const transientNetwork = (err) =>
293
+ !gotResponse && err?.name !== "AbortError" && RETRYABLE_NET.has(err?.cause?.code ?? err?.code);
294
+ let attempt = 0;
295
+ let gotResponse = false;
296
+ for (;;) {
297
+ gotResponse = false;
298
+ if (cancelled()) return { isError: true, content: [{ type: "text", text: "Request cancelled by the caller." }] };
258
299
  const ctrl = new AbortController();
259
300
  const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
301
+ const onCancel = () => ctrl.abort();
302
+ callerSignal?.addEventListener?.("abort", onCancel, { once: true });
260
303
  try {
261
304
  const res = await fetchImpl(url, {
262
305
  method,
@@ -264,7 +307,14 @@ export function createServer({
264
307
  body: reqBody,
265
308
  signal: ctrl.signal,
266
309
  });
310
+ gotResponse = true;
267
311
  const body = await res.text();
312
+ if (canRetry && !cancelled() && attempt < retryDelaysMs.length && gatewayFailure(res.status, body)) {
313
+ clearTimeout(timer);
314
+ callerSignal?.removeEventListener?.("abort", onCancel);
315
+ await pause(retryDelaysMs[attempt++]);
316
+ continue;
317
+ }
268
318
  if (!res.ok) {
269
319
  const hint = hintFor(res.status, resolvedPath);
270
320
  lastError = {
@@ -292,11 +342,23 @@ export function createServer({
292
342
  lastError = null;
293
343
  return { content: [{ type: "text", text: body }] };
294
344
  } catch (err) {
295
- const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
345
+ if (canRetry && !cancelled() && attempt < retryDelaysMs.length && transientNetwork(err)) {
346
+ clearTimeout(timer);
347
+ callerSignal?.removeEventListener?.("abort", onCancel);
348
+ await pause(retryDelaysMs[attempt++]);
349
+ continue;
350
+ }
351
+ if (cancelled()) return { isError: true, content: [{ type: "text", text: "Request cancelled by the caller." }] };
352
+ const code = err?.cause?.code ?? err?.code;
353
+ const msg = err?.name === "AbortError"
354
+ ? `timed out after ${REQUEST_TIMEOUT_MS}ms`
355
+ : `${err?.message || String(err)}${code ? ` (${code})` : ""}`;
296
356
  lastError = { path: resolvedPath, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
297
357
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
298
358
  } finally {
299
359
  clearTimeout(timer);
360
+ callerSignal?.removeEventListener?.("abort", onCancel);
361
+ }
300
362
  }
301
363
  }
302
364
 
@@ -330,8 +392,8 @@ export function createServer({
330
392
  handler = LOCAL_HANDLERS[tool.local];
331
393
  if (!handler) throw new Error(`[twitterapis-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/server.js has none`);
332
394
  } else {
333
- handler = async (args) => {
334
- 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 });
335
397
  if (result?.isError && lastError) {
336
398
  let resolved = null;
337
399
  try { resolved = resolvePathParams(tool.path, tool.pathParams || [], args).path; } catch { resolved = null; }
package/src/tools.js CHANGED
@@ -605,7 +605,7 @@ export const TOOLS = [
605
605
  name: "twitter_tweet_quotes",
606
606
  path: "/twitter/tweet/quotes",
607
607
  description:
608
- "List the tweets that QUOTE a specific tweet, cursor-paginated as full tweet objects, so you get the commentary people attached rather than just a number. Different from twitter_tweet_retweeters (a plain retweet carries no text) and from twitter_tweet_replies (a reply is not a quote). IMPORTANT, state this to the user whenever you report a number from it: this endpoint is SEARCH-BACKED, because X exposes no dedicated quote-tweets operation, so it runs the query quoted_tweet_id:<id> against X's search index. The returned 'count' is therefore how many quotes THIS SEARCH returned, never the tweet's true total; the authoritative total is 'quote_count' on the tweet object from twitter_tweet_detail, and the two WILL differ because of index lag and because deleted, protected, suspended and region-withheld quotes are absent from search. Every response carries 'source' (always \"search\"), 'search_query' (the exact query sent), and 'quote_matched' (how many returned tweets demonstrably quote the requested id). quote_matched equal to count means every row is genuine; quote_matched 0 on a NON-EMPTY page means X stopped honouring the operator and the rows are junk, so discard that page rather than reporting it.",
608
+ "List the tweets that QUOTE a specific tweet, cursor-paginated as full tweet objects, so you get the commentary people attached rather than just a number. Different from twitter_tweet_retweeters (a plain retweet carries no text) and from twitter_tweet_replies (a reply is not a quote). IMPORTANT, state this to the user whenever you report a number from it: this endpoint is SEARCH-BACKED, because X exposes no dedicated quote-tweets operation, so it runs the query quoted_tweet_id:<id> against X's search index. The returned 'count' is therefore how many quotes THIS SEARCH returned, never the tweet's true total; the authoritative total is 'quote_count' on the tweet object from twitter_tweet_detail, and the two WILL differ because of index lag and because deleted, protected, suspended and region-withheld quotes are absent from search. Every response carries 'source' (always \"search\"), 'search_query' (the exact query sent), and 'quote_matched' (how many returned tweets demonstrably quote the requested id). quote_matched equal to count means every row is genuine; quote_matched 0 on a NON-EMPTY page means X stopped honouring the operator and the rows are junk, so discard that page rather than reporting it. An empty first Top page is served from Latest instead of reading as zero quotes: the response then says product_used \"Latest\" and top_fallback true, and its next_cursor keeps paging that Latest list, so pass it back unchanged.",
609
609
  shape: {
610
610
  id: z.string().optional().describe(
611
611
  "Tweet/post numeric id (e.g. \"1789012345678901234\"). Provide exactly one of id or url.",