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