@twitterapis/mcp 0.14.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 +27 -0
- package/package.json +2 -2
- package/src/server.js +133 -10
- package/src/tools.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.15.0 (2026-09-29)
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Agent-actionable paywall.** A missing key, a rejected credential (401
|
|
22
|
+
unauthorized), an empty balance (402 insufficient_credits) and a missing or
|
|
23
|
+
expired X session (409 session_required, 401 session_dead) now return a structured payload
|
|
24
|
+
instead of a prose hint: `needs` (`account`, `valid_key`, `credits` or
|
|
25
|
+
`x_session`), the page to send the user to (`action_url`: signup, dashboard,
|
|
26
|
+
buy credits) or the tool to call next (`next_tool`: twitter_user_login), and
|
|
27
|
+
one sentence the agent can relay, both in the text and as `structuredContent`.
|
|
28
|
+
Other failures keep their existing hints.
|
|
29
|
+
|
|
3
30
|
## 0.14.0 (2026-09-29)
|
|
4
31
|
|
|
5
32
|
- **`@twitterapis/mcp/server` export and `authHeaders`.** The package now
|
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",
|
|
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
|
@@ -41,6 +41,95 @@ const NOT_FOUND_HINTS = [
|
|
|
41
41
|
const DEFAULT_NOT_FOUND_HINT =
|
|
42
42
|
" (not found. The user, tweet, or list may have been deleted or the id is wrong)";
|
|
43
43
|
|
|
44
|
+
// AGENT-ACTIONABLE PAYWALL. A missing key, a rejected key, an empty balance and
|
|
45
|
+
// a missing X session are the moments a user decides whether to keep going, and
|
|
46
|
+
// they happen inside an agent's turn. The payload names the exact page (or tool)
|
|
47
|
+
// so the agent can say "top up here, then I will retry". It is appended to the
|
|
48
|
+
// text (every client reads that) and returned as structuredContent. Which
|
|
49
|
+
// failures ARE a paywall is decided from the API's own response BODY, not the
|
|
50
|
+
// status alone (the API's bodies, read 2026-09-29 from scraper/src/server/auth.ts
|
|
51
|
+
// and routes/actions.ts): 401 {"error":"unauthorized"}, 402
|
|
52
|
+
// {"error":"insufficient_credits"}, 409 {"error":"session_required"|"session_dead"}.
|
|
53
|
+
export const SIGNUP_URL = "https://www.twitterapis.com/signup?utm_source=mcp&utm_medium=tool_error";
|
|
54
|
+
export const API_KEYS_URL = "https://www.twitterapis.com/dashboard?utm_source=mcp&utm_medium=tool_error";
|
|
55
|
+
export const TOP_UP_URL = "https://www.twitterapis.com/dashboard/buy-credits?utm_source=mcp&utm_medium=tool_error";
|
|
56
|
+
|
|
57
|
+
export function paywallFor(kind) {
|
|
58
|
+
if (kind === "no_key") {
|
|
59
|
+
return {
|
|
60
|
+
needs: "account",
|
|
61
|
+
message:
|
|
62
|
+
"Missing TWITTERAPIS_KEY: no API key is set. Sign up free at twitterapis.com (new accounts start " +
|
|
63
|
+
"with free credit, no card), copy the key from the dashboard, set TWITTERAPIS_KEY in the MCP " +
|
|
64
|
+
"client config, then retry this call.",
|
|
65
|
+
action_url: SIGNUP_URL,
|
|
66
|
+
api_keys_url: API_KEYS_URL,
|
|
67
|
+
retry: "same call, after the key is set",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (kind === "bad_key") {
|
|
71
|
+
return {
|
|
72
|
+
needs: "valid_key",
|
|
73
|
+
message:
|
|
74
|
+
"The twitterapis.com credential was rejected (the API key is invalid, revoked or rotated, or the " +
|
|
75
|
+
"connected app was disconnected). Copy a current key from the dashboard and set TWITTERAPIS_KEY, " +
|
|
76
|
+
"or reconnect the app, then retry this call.",
|
|
77
|
+
action_url: API_KEYS_URL,
|
|
78
|
+
retry: "same call, after the key is replaced or the app reconnected",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (kind === "credits") {
|
|
82
|
+
return {
|
|
83
|
+
needs: "credits",
|
|
84
|
+
message:
|
|
85
|
+
"The twitterapis.com account is out of credits. Top up (pay as you go, no subscription), then " +
|
|
86
|
+
"retry this call; nothing was charged for the failed request. twitter_account_me shows the balance.",
|
|
87
|
+
action_url: TOP_UP_URL,
|
|
88
|
+
retry: "same call, after topping up",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (kind === "x_session") {
|
|
92
|
+
return {
|
|
93
|
+
needs: "x_session",
|
|
94
|
+
message:
|
|
95
|
+
"This action needs a working linked X account: writes and account-only reads act as the user's " +
|
|
96
|
+
"own X session, and none is linked or the linked one has expired. Link or re-link it with the " +
|
|
97
|
+
"twitter_user_login tool (or twitter_customer_session with auth_token and ct0), then retry this call.",
|
|
98
|
+
next_tool: "twitter_user_login",
|
|
99
|
+
retry: "same call, after an X session is linked",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function classifyPaywall(status, bodyText) {
|
|
106
|
+
let body = null;
|
|
107
|
+
try {
|
|
108
|
+
body = JSON.parse(bodyText);
|
|
109
|
+
} catch {
|
|
110
|
+
body = null;
|
|
111
|
+
}
|
|
112
|
+
const err = body && typeof body.error === "string" ? body.error : "";
|
|
113
|
+
// A dead X session is a 401 {"error":"session_dead"} (routes/actions.ts,
|
|
114
|
+
// routes/customer.ts), NOT a bad API key: telling the user to rotate a working
|
|
115
|
+
// key when their X cookies expired is the wrong fix. A 401 whose message is
|
|
116
|
+
// about the internal headers is a server wiring fault, not the user's key.
|
|
117
|
+
if (status === 401 && err === "session_dead") return "x_session";
|
|
118
|
+
if (status === 401 && err === "unauthorized" && !/x-internal/i.test(String(body?.message || ""))) return "bad_key";
|
|
119
|
+
if (status === 402 && err === "insufficient_credits") return "credits";
|
|
120
|
+
if (status === 409 && err === "session_required") return "x_session";
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function paywallResult(kind, detail = "") {
|
|
125
|
+
const p = paywallFor(kind);
|
|
126
|
+
return {
|
|
127
|
+
isError: true,
|
|
128
|
+
content: [{ type: "text", text: `${p.message}${detail ? ` (${detail})` : ""}\n\n${JSON.stringify(p)}` }],
|
|
129
|
+
structuredContent: p,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
44
133
|
export function hintFor(status, path) {
|
|
45
134
|
if (status === 401) return " (invalid or missing API key, verify TWITTERAPIS_KEY at https://www.twitterapis.com/dashboard)";
|
|
46
135
|
if (status === 402) return " (insufficient credits, top up at https://www.twitterapis.com/dashboard)";
|
|
@@ -76,6 +165,9 @@ export const INSTRUCTIONS =
|
|
|
76
165
|
* (TWITTERAPIS_FEEDBACK_DIR); a remote host gives each
|
|
77
166
|
* caller its own directory
|
|
78
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]
|
|
79
171
|
* @param {Record<string,string>} [opts.authHeaders]
|
|
80
172
|
* headers that authenticate each call INSTEAD of the API key. For a host
|
|
81
173
|
* that has already authenticated the caller some other way (an OAuth
|
|
@@ -89,6 +181,8 @@ export function createServer({
|
|
|
89
181
|
feedbackEnv = process.env,
|
|
90
182
|
fetchImpl = fetch,
|
|
91
183
|
authHeaders = null,
|
|
184
|
+
sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
185
|
+
retryDelaysMs = [3000, 8000],
|
|
92
186
|
} = {}) {
|
|
93
187
|
const BASE_URL = String(baseUrl).replace(/\/+$/, "");
|
|
94
188
|
const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
@@ -111,15 +205,7 @@ export function createServer({
|
|
|
111
205
|
// before building the query string or body, so a pathParams arg never leaks
|
|
112
206
|
// into either.
|
|
113
207
|
async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
|
|
114
|
-
if (!apiKey && !authHeaders)
|
|
115
|
-
return {
|
|
116
|
-
isError: true,
|
|
117
|
-
content: [{
|
|
118
|
-
type: "text",
|
|
119
|
-
text: "Missing TWITTERAPIS_KEY (invalid or missing API key, get one at https://www.twitterapis.com/signup and set it in your MCP client config).",
|
|
120
|
-
}],
|
|
121
|
-
};
|
|
122
|
-
}
|
|
208
|
+
if (!apiKey && !authHeaders) return paywallResult("no_key");
|
|
123
209
|
// Fill {name} URL segments from args and strip those keys, so a pathParams arg
|
|
124
210
|
// (e.g. a monitor/webhook id) never also leaks into the query string or JSON
|
|
125
211
|
// body. A missing value fails loudly rather than shipping a request that still
|
|
@@ -174,6 +260,26 @@ export function createServer({
|
|
|
174
260
|
}
|
|
175
261
|
}
|
|
176
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;
|
|
177
283
|
const ctrl = new AbortController();
|
|
178
284
|
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
179
285
|
try {
|
|
@@ -183,7 +289,13 @@ export function createServer({
|
|
|
183
289
|
body: reqBody,
|
|
184
290
|
signal: ctrl.signal,
|
|
185
291
|
});
|
|
292
|
+
gotResponse = true;
|
|
186
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
|
+
}
|
|
187
299
|
if (!res.ok) {
|
|
188
300
|
const hint = hintFor(res.status, resolvedPath);
|
|
189
301
|
lastError = {
|
|
@@ -201,6 +313,8 @@ export function createServer({
|
|
|
201
313
|
res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
|
|
202
314
|
? ""
|
|
203
315
|
: " If this blocked the user's task and looks like a defect or a missing capability, draft a report with twitter_feedback_send (queued locally until the user reviews it).";
|
|
316
|
+
const pw = classifyPaywall(res.status, body);
|
|
317
|
+
if (pw) return paywallResult(pw, `HTTP ${res.status}: ${body.slice(0, 1200)}`);
|
|
204
318
|
return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
|
|
205
319
|
}
|
|
206
320
|
// A success clears the record so a later draft never inherits an old
|
|
@@ -209,12 +323,21 @@ export function createServer({
|
|
|
209
323
|
lastError = null;
|
|
210
324
|
return { content: [{ type: "text", text: body }] };
|
|
211
325
|
} catch (err) {
|
|
212
|
-
|
|
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})` : ""}`;
|
|
213
335
|
lastError = { path: resolvedPath, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
|
|
214
336
|
return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
|
|
215
337
|
} finally {
|
|
216
338
|
clearTimeout(timer);
|
|
217
339
|
}
|
|
340
|
+
}
|
|
218
341
|
}
|
|
219
342
|
|
|
220
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.",
|