@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 +14 -0
- package/package.json +2 -2
- package/src/server.js +41 -1
- package/src/tools.js +1 -1
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.
|
|
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
|
-
|
|
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.",
|