@twitterapis/mcp 0.15.0 → 0.16.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 (2026-09-29)
4
+
5
+ ### Changed
6
+
7
+ - **A read rides out an API restart.** A read (GET) that gets the gateway's
8
+ HTML 502/503, or a refused connection, is retried after 3 and
9
+ then 8 seconds, so a deploy restart no longer surfaces as a Bad Gateway error.
10
+ The API's own JSON errors, gateway timeouts, DNS or TLS failures
11
+ and every write are never retried, so a request the API may already have
12
+ handled is not sent twice.
13
+ - `twitter_tweet_quotes` explains the Top fallback: an empty first Top page is
14
+ served from Latest (`product_used`, `top_fallback`) and its `next_cursor`
15
+ keeps paging that list.
16
+
3
17
  ## 0.15.0 (2026-09-29)
4
18
 
5
19
  ### 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.0",
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;
@@ -255,6 +260,26 @@ 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
+ const gatewayFailure = (status, text) =>
276
+ (status === 502 || status === 503) && /^\s*<(!doctype|html)/i.test(text);
277
+ const transientNetwork = (err) =>
278
+ !gotResponse && err?.name !== "AbortError" && RETRYABLE_NET.has(err?.cause?.code ?? err?.code);
279
+ let attempt = 0;
280
+ let gotResponse = false;
281
+ for (;;) {
282
+ gotResponse = false;
258
283
  const ctrl = new AbortController();
259
284
  const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
260
285
  try {
@@ -264,7 +289,13 @@ export function createServer({
264
289
  body: reqBody,
265
290
  signal: ctrl.signal,
266
291
  });
292
+ gotResponse = true;
267
293
  const body = await res.text();
294
+ if (canRetry && attempt < retryDelaysMs.length && gatewayFailure(res.status, body)) {
295
+ clearTimeout(timer);
296
+ await sleepImpl(retryDelaysMs[attempt++]);
297
+ continue;
298
+ }
268
299
  if (!res.ok) {
269
300
  const hint = hintFor(res.status, resolvedPath);
270
301
  lastError = {
@@ -292,12 +323,21 @@ export function createServer({
292
323
  lastError = null;
293
324
  return { content: [{ type: "text", text: body }] };
294
325
  } catch (err) {
295
- const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
326
+ if (canRetry && attempt < retryDelaysMs.length && transientNetwork(err)) {
327
+ clearTimeout(timer);
328
+ await sleepImpl(retryDelaysMs[attempt++]);
329
+ continue;
330
+ }
331
+ const code = err?.cause?.code ?? err?.code;
332
+ const msg = err?.name === "AbortError"
333
+ ? `timed out after ${REQUEST_TIMEOUT_MS}ms`
334
+ : `${err?.message || String(err)}${code ? ` (${code})` : ""}`;
296
335
  lastError = { path: resolvedPath, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
297
336
  return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
298
337
  } finally {
299
338
  clearTimeout(timer);
300
339
  }
340
+ }
301
341
  }
302
342
 
303
343
  const server = new McpServer({ name: "twitterapis", version: VERSION }, { instructions: INSTRUCTIONS });
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.",