@twitterapis/mcp 0.14.0 → 0.15.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 +13 -0
- package/package.json +2 -2
- package/src/server.js +92 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.0 (2026-09-29)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Agent-actionable paywall.** A missing key, a rejected credential (401
|
|
8
|
+
unauthorized), an empty balance (402 insufficient_credits) and a missing or
|
|
9
|
+
expired X session (409 session_required, 401 session_dead) now return a structured payload
|
|
10
|
+
instead of a prose hint: `needs` (`account`, `valid_key`, `credits` or
|
|
11
|
+
`x_session`), the page to send the user to (`action_url`: signup, dashboard,
|
|
12
|
+
buy credits) or the tool to call next (`next_tool`: twitter_user_login), and
|
|
13
|
+
one sentence the agent can relay, both in the text and as `structuredContent`.
|
|
14
|
+
Other failures keep their existing hints.
|
|
15
|
+
|
|
3
16
|
## 0.14.0 (2026-09-29)
|
|
4
17
|
|
|
5
18
|
- **`@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.15.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",
|
|
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)";
|
|
@@ -111,15 +200,7 @@ export function createServer({
|
|
|
111
200
|
// before building the query string or body, so a pathParams arg never leaks
|
|
112
201
|
// into either.
|
|
113
202
|
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
|
-
}
|
|
203
|
+
if (!apiKey && !authHeaders) return paywallResult("no_key");
|
|
123
204
|
// Fill {name} URL segments from args and strip those keys, so a pathParams arg
|
|
124
205
|
// (e.g. a monitor/webhook id) never also leaks into the query string or JSON
|
|
125
206
|
// body. A missing value fails loudly rather than shipping a request that still
|
|
@@ -201,6 +282,8 @@ export function createServer({
|
|
|
201
282
|
res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
|
|
202
283
|
? ""
|
|
203
284
|
: " 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).";
|
|
285
|
+
const pw = classifyPaywall(res.status, body);
|
|
286
|
+
if (pw) return paywallResult(pw, `HTTP ${res.status}: ${body.slice(0, 1200)}`);
|
|
204
287
|
return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
|
|
205
288
|
}
|
|
206
289
|
// A success clears the record so a later draft never inherits an old
|