@twitterapis/mcp 0.13.1 → 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 CHANGED
@@ -1,5 +1,39 @@
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
+
16
+ ## 0.14.0 (2026-09-29)
17
+
18
+ - **`@twitterapis/mcp/server` export and `authHeaders`.** The package now
19
+ exports `createServer` at `@twitterapis/mcp/server`, so a host can serve the
20
+ same tools remotely. `createServer({ authHeaders })` authenticates each call
21
+ with headers the host supplies instead of an API key, for a host that has
22
+ already authenticated the caller another way (an OAuth token resolved to an
23
+ account). Without it, behavior is unchanged: the API key is sent as before.
24
+ The `exports` map keeps `.` (the stdio entry) and `./package.json`.
25
+
26
+ - **Per-caller server state.** The server is now built by `createServer()` in
27
+ `src/server.js`, which holds the API key and the last-failed-call record in
28
+ its own closure instead of module globals. Under stdio nothing changes (one
29
+ process, one caller); it is the prerequisite for serving the same tools
30
+ remotely, where one process serves many callers and a global would send one
31
+ caller's requests with another's key. `src/index.js` is now only the stdio
32
+ entry and the one place config is read from the environment.
33
+ `test/per-caller-state.test.mjs` proves two servers in one process never
34
+ share a key or a failure record (red when `lastError` is hoisted back to
35
+ module scope).
36
+
3
37
  ## 0.13.1 (2026-09-28)
4
38
 
5
39
  - **`twitter_user_search` no longer claims bio matching.** X's People search
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.13.1",
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",
@@ -12,6 +12,11 @@
12
12
  "twitterapis-mcp": "src/index.js"
13
13
  },
14
14
  "main": "src/index.js",
15
+ "exports": {
16
+ ".": "./src/index.js",
17
+ "./server": "./src/server.js",
18
+ "./package.json": "./package.json"
19
+ },
15
20
  "files": [
16
21
  "src",
17
22
  "README.md",
@@ -28,7 +33,7 @@
28
33
  "build": "node scripts/gen-tools.mjs --write && node scripts/gen-manifest-tools.mjs --write",
29
34
  "build:check": "node scripts/gen-tools.mjs --check",
30
35
  "openapi:refresh": "node scripts/openapi-refresh.mjs",
31
- "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/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",
32
37
  "prepublishOnly": "npm test && node test/publish-provenance.mjs && node scripts/prepublish-version-class.mjs",
33
38
  "check:openapi-parity": "node test/openapi-parity.mjs",
34
39
  "check:body-mode-parity": "node test/body-mode-parity.mjs",
package/src/index.js CHANGED
@@ -6,33 +6,29 @@
6
6
  // lists, mentions, likes, bookmarks, DMs, home timeline) plus write actions
7
7
  // (post/delete tweet, like, retweet, bookmark, follow, and their inverses).
8
8
  // Each tool is a thin, typed wrapper over a REST endpoint at
9
- // https://api.twitterapis.com. The server holds no state and forwards your API
10
- // key on every call. The tool catalog lives in ./tools.js.
9
+ // https://api.twitterapis.com. The server forwards your API key on every call.
10
+ // The tool catalog lives in ./tools.js; the server itself is built by
11
+ // createServer() in ./server.js, which holds every per-caller value (key, last
12
+ // failure) in its own closure so the same code can serve many callers.
11
13
  //
12
- // Config (env):
14
+ // This file is the stdio entry point and the ONLY place config is read from
15
+ // the environment:
13
16
  // TWITTERAPIS_KEY required. Your key from https://www.twitterapis.com/signup
14
17
  // TWITTERAPIS_BASE_URL optional. Defaults to https://api.twitterapis.com
15
18
  // TWITTERAPIS_TIMEOUT_MS optional. Per-request timeout (default 30000)
16
19
  //
17
20
  // Run: npx -y @twitterapis/mcp@latest (stdio transport)
18
21
 
19
- import { createRequire } from "node:module";
20
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
+ import { TOOLS } from "./tools.js";
24
+ import { createServer, hintFor, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS } from "./server.js";
22
25
 
23
- // Single source of truth for the version. Previously this was a literal in two
24
- // places and drifted: the package shipped 0.5.0 while the MCP handshake and the
25
- // outbound user-agent both still advertised 0.3.0.
26
- const VERSION = createRequire(import.meta.url)("../package.json").version;
27
- import { TOOLS, buildQuery, resolvePathParams, MissingPathParamError } from "./tools.js";
28
- import { createFeedbackHandler } from "./feedback.js";
26
+ // Re-exported so existing importers of the entry point keep working.
27
+ export { hintFor };
29
28
 
30
29
  const API_KEY = process.env.TWITTERAPIS_KEY;
31
- const BASE_URL = (
32
- process.env.TWITTERAPIS_BASE_URL || "https://api.twitterapis.com"
33
- ).replace(/\/+$/, "");
30
+ const BASE_URL = process.env.TWITTERAPIS_BASE_URL || DEFAULT_BASE_URL;
34
31
 
35
- const DEFAULT_TIMEOUT_MS = 30000;
36
32
  // A malformed TWITTERAPIS_TIMEOUT_MS (non-numeric, or <= 0) used to reach
37
33
  // setTimeout() unvalidated. Number("30000ms") and Number("60,000") are both
38
34
  // NaN, and Node clamps a NaN or sub-1 delay to ~1ms (verified directly:
@@ -64,228 +60,24 @@ if (rawTimeoutEnv) {
64
60
  // confirmed live 2026-08-18 (Smithery: "Initialization failed... could not be
65
61
  // automatically scanned", HTTP 405). Warn and continue; a real tool CALL made
66
62
  // with no key still fails clearly, at the point of the call, same as it
67
- // already does for a bad key (see the 401 branch below).
63
+ // already does for a bad key (see the 401 branch in server.js).
68
64
  if (!API_KEY) {
69
65
  console.error(
70
66
  "[twitterapis-mcp] Missing TWITTERAPIS_KEY. Get a key at https://www.twitterapis.com/signup and set it in your MCP client config. Tools are registered but every call will fail until it is set.",
71
67
  );
72
68
  }
73
69
 
74
- // ── REST call ────────────────────────────────────────────────────────────────
75
- // Most endpoints (GET reads and the simple POST writes alike) read their params
76
- // from the query string, so the same buildQuery path serves both and only the
77
- // HTTP method differs. A few POST endpoints (customer/session, user_login,
78
- // media/upload) instead read a JSON request body; those tools set jsonBody:true
79
- // and callEndpoint sends the args in the body rather than the query string. A
80
- // handful of monitoring endpoints (/monitor/{id}, /webhook/{id}, ...) carry a
81
- // REST path parameter instead: those tools set pathParams (the arg names to
82
- // substitute into the URL template) and callEndpoint splices them into path
83
- // before building the query string or body, so a pathParams arg never leaks
84
- // into either.
85
- // The last tool call that failed, so a feedback draft can carry the endpoint,
86
- // status and request id without the model retyping them. Set in callEndpoint's
87
- // error branch; the tool name is added by the registration wrapper below.
88
- let lastError = null;
89
-
90
- // The 404 hint used to be a flat status-only ternary telling EVERY caller that
91
- // "the user, tweet, or list may have been deleted or the id is wrong". For a
92
- // feedback id that sentence is simply wrong, and it sends a customer chasing a
93
- // report id off to look at a tweet. The feedback routes are the first mounted
94
- // outside the /twitter surface, so they are the first place the assumption is
95
- // plainly visible; /account/* would have been next.
96
- //
97
- // ONLY THE 404 VARIES. Every other status is about the KEY, the CREDIT
98
- // balance, the caller's SESSION, or OUR service, and each of those reads
99
- // identically on every endpoint. Adding per-path branches for them would be
100
- // surface area with no reader.
101
- const NOT_FOUND_HINTS = [
102
- [/^\/feedback/, " (not found. No feedback report with that id on this account, and an id from another account will not resolve here. Use the id returned by twitter_feedback_send action=send.)"],
103
- [/^\/account/, " (not found. That account resource does not exist for this key.)"],
104
- ];
105
- const DEFAULT_NOT_FOUND_HINT =
106
- " (not found. The user, tweet, or list may have been deleted or the id is wrong)";
107
-
108
- export function hintFor(status, path) {
109
- if (status === 401) return " (invalid or missing API key, verify TWITTERAPIS_KEY at https://www.twitterapis.com/dashboard)";
110
- if (status === 402) return " (insufficient credits, top up at https://www.twitterapis.com/dashboard)";
111
- if (status === 403) return " (access forbidden. The resource may be private or your plan does not include this endpoint)";
112
- if (status === 404) {
113
- for (const [re, h] of NOT_FOUND_HINTS) if (re.test(path || "")) return h;
114
- return DEFAULT_NOT_FOUND_HINT;
115
- }
116
- if (status === 409) return " (no authenticated X session for this key. Write actions and account-only reads (likes, bookmarks, DMs, home timeline, follow, post) require linking an X account/session to your key first; see https://www.twitterapis.com/dashboard)";
117
- if (status === 429) return " (rate limited. Wait a few seconds and retry; reduce request frequency or increase TWITTERAPIS_TIMEOUT_MS if needed)";
118
- if (status >= 500) return " (upstream API error. Retry in a moment; if persistent, check https://www.twitterapis.com/status)";
119
- return "";
120
- }
121
-
122
- async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
123
- if (!API_KEY) {
124
- return {
125
- isError: true,
126
- content: [{
127
- type: "text",
128
- 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).",
129
- }],
130
- };
131
- }
132
- // Fill {name} URL segments from args and strip those keys, so a pathParams arg
133
- // (e.g. a monitor/webhook id) never also leaks into the query string or JSON
134
- // body. A missing value fails loudly rather than shipping a request that still
135
- // contains the literal "{id}" against the API.
136
- let resolvedPath, all;
137
- try {
138
- ({ path: resolvedPath, args: all } = resolvePathParams(path, pathParams, args));
139
- } catch (err) {
140
- if (err instanceof MissingPathParamError) {
141
- return { isError: true, content: [{ type: "text", text: err.message }] };
142
- }
143
- throw err;
144
- }
145
-
146
- const headers = {
147
- // The API accepts either header; send both for maximum compatibility.
148
- Authorization: `Bearer ${API_KEY}`,
149
- "x-api-key": API_KEY,
150
- accept: "application/json",
151
- "user-agent": `twitterapis-mcp/${VERSION}`,
152
- };
153
-
154
- let url;
155
- let reqBody;
156
- if (jsonBody) {
157
- // Endpoints whose handler reads a JSON request body (customer/session,
158
- // user_login, media/upload). Send every arg in the body: for customer/session
159
- // and user_login the credentials ARE the payload the handler reads from the
160
- // body, so they must NOT be diverted into x-* headers the way per-call inline
161
- // creds are on the query-string tools.
162
- url = `${BASE_URL}${resolvedPath}`;
163
- headers["content-type"] = "application/json";
164
- reqBody = JSON.stringify(all);
165
- } else {
166
- // Pull per-call inline credentials out of args so they travel as request
167
- // headers, never the query string (the API reads x-auth-token / x-ct0; passing
168
- // them as query params would leak them into URLs and access logs). When
169
- // supplied, this one API key acts as that account; otherwise the key's linked
170
- // session is used. Lets a single key act as many accounts.
171
- const { auth_token, ct0, user_agent, proxy_url, ...rest } = all;
172
- const q = buildQuery(rest);
173
- url = `${BASE_URL}${resolvedPath}${q ? `?${q}` : ""}`;
174
- if (auth_token && ct0) {
175
- headers["x-auth-token"] = auth_token;
176
- headers["x-ct0"] = ct0;
177
- if (user_agent) headers["x-user-agent"] = user_agent;
178
- if (proxy_url) headers["x-proxy-url"] = proxy_url;
179
- }
180
- }
181
-
182
- const ctrl = new AbortController();
183
- const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
184
- try {
185
- const res = await fetch(url, {
186
- method,
187
- headers,
188
- body: reqBody,
189
- signal: ctrl.signal,
190
- });
191
- const body = await res.text();
192
- if (!res.ok) {
193
- const hint = hintFor(res.status, resolvedPath);
194
- lastError = {
195
- path: resolvedPath,
196
- method,
197
- status: res.status,
198
- requestId: res.headers.get("x-request-id") || undefined,
199
- ts: Date.now(),
200
- };
201
- // Credential, credit, session, rate-limit and not-found failures are the
202
- // caller's situation (a 404 is almost always a wrong id), not a product
203
- // defect; everything else may be one, and the model reads error bodies
204
- // closely, so the pointer lives here.
205
- const feedbackHint =
206
- res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
207
- ? ""
208
- : " 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).";
209
- return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
210
- }
211
- // A success clears the record so a later draft never inherits an old
212
- // failure's endpoint or request id (review 2026-09-04: a delete's draft
213
- // carried the previous update's 404).
214
- lastError = null;
215
- return { content: [{ type: "text", text: body }] };
216
- } catch (err) {
217
- const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
218
- lastError = { path: resolvedPath, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
219
- return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
220
- } finally {
221
- clearTimeout(timer);
222
- }
223
- }
224
-
225
- // ── MCP server ───────────────────────────────────────────────────────────────
226
- // Standing instructions the client hands its model alongside the tool list.
227
- // This is the trigger list for feedback, in the place a model actually reads.
228
- const INSTRUCTIONS =
229
- "twitterapis.com MCP server. Read tools cost credits per call (most $0.0008); account, monitoring and feedback tools are free. " +
230
- "If a twitterapis tool call fails with an error other than 401/402/409/429 and the user has to work around it, if the user asks for something no twitterapis tool covers, " +
231
- "if a documented field comes back empty or wrong, or if the user is clearly frustrated with a result, draft a report with twitter_feedback_send (action \"draft\"). " +
232
- "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\". " +
233
- "Before drafting a report that a parameter is IGNORED or a field is EMPTY, re-run the call with a distinctive value that could only match if the parameter was honoured, and with the phrase quoted; " +
234
- "if either comes back on topic the issue is ranking or matching, so title it that way and say what the control showed.";
235
-
236
- const server = new McpServer({ name: "twitterapis", version: VERSION }, { instructions: INSTRUCTIONS });
237
-
238
- // Handlers for tools that carry local: "<name>" in the catalog. A name the
239
- // catalog uses and this map lacks is a boot-time failure, never a silent
240
- // passthrough to the API with the local args attached.
241
- const LOCAL_HANDLERS = {
242
- feedback: createFeedbackHandler({
243
- callEndpoint,
244
- version: VERSION,
245
- getClientInfo: () => server.server.getClientVersion(),
246
- getLastError: () => lastError,
247
- }),
248
- };
249
-
250
- for (const tool of TOOLS) {
251
- const method = tool.method || "GET";
252
- // Surface read/write/destructive intent so MCP clients can warn before a
253
- // mutating call (default = read-only).
254
- const annotations = {
255
- title: tool.name,
256
- readOnlyHint: !tool.write,
257
- destructiveHint: Boolean(tool.destructive),
258
- openWorldHint: true,
259
- };
260
- let handler;
261
- if (tool.local) {
262
- handler = LOCAL_HANDLERS[tool.local];
263
- if (!handler) throw new Error(`[twitterapis-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/index.js has none`);
264
- } else {
265
- handler = async (args) => {
266
- const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []);
267
- if (result?.isError && lastError) {
268
- let resolved = null;
269
- try { resolved = resolvePathParams(tool.path, tool.pathParams || [], args).path; } catch { resolved = null; }
270
- // Name the tool only when BOTH method and path match the recorded
271
- // failure; two tools share /monitor/{id} (POST update, DELETE remove).
272
- if (resolved === lastError.path && method === lastError.method) lastError.tool = tool.name;
273
- }
274
- return result;
275
- };
276
- }
277
- server.registerTool(
278
- tool.name,
279
- { description: tool.description, inputSchema: tool.shape, annotations },
280
- handler,
281
- );
282
- }
283
-
284
70
  async function main() {
71
+ const { server, baseUrl } = createServer({
72
+ apiKey: API_KEY,
73
+ baseUrl: BASE_URL,
74
+ timeoutMs: REQUEST_TIMEOUT_MS,
75
+ feedbackEnv: process.env,
76
+ });
285
77
  const transport = new StdioServerTransport();
286
78
  await server.connect(transport);
287
79
  // Logs go to stderr so they never corrupt the stdio JSON-RPC stream.
288
- console.error(`[twitterapis-mcp] ready · ${TOOLS.length} tools · base ${BASE_URL}`);
80
+ console.error(`[twitterapis-mcp] ready · ${TOOLS.length} tools · base ${baseUrl}`);
289
81
  }
290
82
 
291
83
  main().catch((err) => {
package/src/server.js ADDED
@@ -0,0 +1,353 @@
1
+ // createServer(): one McpServer per caller, holding that caller's key and last
2
+ // failure in its own closure.
3
+ //
4
+ // WHY A FACTORY: the key and the last-failed-call record used to be module
5
+ // globals. Under stdio there is one process per user, so that was harmless;
6
+ // served remotely, one process serves many users, and a module global would
7
+ // send one caller's requests with another caller's key and hand one caller's
8
+ // failure (path, request id) to another caller's feedback draft. Every piece
9
+ // of per-caller state now lives inside createServer, and nothing in this file
10
+ // reads process.env: the entry point (index.js for stdio) resolves config and
11
+ // passes it in.
12
+
13
+ import { createRequire } from "node:module";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { TOOLS, buildQuery, resolvePathParams, MissingPathParamError } from "./tools.js";
16
+ import { createFeedbackHandler } from "./feedback.js";
17
+
18
+ // Single source of truth for the version. Previously this was a literal in two
19
+ // places and drifted: the package shipped 0.5.0 while the MCP handshake and the
20
+ // outbound user-agent both still advertised 0.3.0.
21
+ export const VERSION = createRequire(import.meta.url)("../package.json").version;
22
+
23
+ export const DEFAULT_BASE_URL = "https://api.twitterapis.com";
24
+ export const DEFAULT_TIMEOUT_MS = 30000;
25
+
26
+ // The 404 hint used to be a flat status-only ternary telling EVERY caller that
27
+ // "the user, tweet, or list may have been deleted or the id is wrong". For a
28
+ // feedback id that sentence is simply wrong, and it sends a customer chasing a
29
+ // report id off to look at a tweet. The feedback routes are the first mounted
30
+ // outside the /twitter surface, so they are the first place the assumption is
31
+ // plainly visible; /account/* would have been next.
32
+ //
33
+ // ONLY THE 404 VARIES. Every other status is about the KEY, the CREDIT
34
+ // balance, the caller's SESSION, or OUR service, and each of those reads
35
+ // identically on every endpoint. Adding per-path branches for them would be
36
+ // surface area with no reader.
37
+ const NOT_FOUND_HINTS = [
38
+ [/^\/feedback/, " (not found. No feedback report with that id on this account, and an id from another account will not resolve here. Use the id returned by twitter_feedback_send action=send.)"],
39
+ [/^\/account/, " (not found. That account resource does not exist for this key.)"],
40
+ ];
41
+ const DEFAULT_NOT_FOUND_HINT =
42
+ " (not found. The user, tweet, or list may have been deleted or the id is wrong)";
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
+
133
+ export function hintFor(status, path) {
134
+ if (status === 401) return " (invalid or missing API key, verify TWITTERAPIS_KEY at https://www.twitterapis.com/dashboard)";
135
+ if (status === 402) return " (insufficient credits, top up at https://www.twitterapis.com/dashboard)";
136
+ if (status === 403) return " (access forbidden. The resource may be private or your plan does not include this endpoint)";
137
+ if (status === 404) {
138
+ for (const [re, h] of NOT_FOUND_HINTS) if (re.test(path || "")) return h;
139
+ return DEFAULT_NOT_FOUND_HINT;
140
+ }
141
+ if (status === 409) return " (no authenticated X session for this key. Write actions and account-only reads (likes, bookmarks, DMs, home timeline, follow, post) require linking an X account/session to your key first; see https://www.twitterapis.com/dashboard)";
142
+ if (status === 429) return " (rate limited. Wait a few seconds and retry; reduce request frequency or increase TWITTERAPIS_TIMEOUT_MS if needed)";
143
+ if (status >= 500) return " (upstream API error. Retry in a moment; if persistent, check https://www.twitterapis.com/status)";
144
+ return "";
145
+ }
146
+
147
+ // Standing instructions the client hands its model alongside the tool list.
148
+ // This is the trigger list for feedback, in the place a model actually reads.
149
+ export const INSTRUCTIONS =
150
+ "twitterapis.com MCP server. Read tools cost credits per call (most $0.0008); account, monitoring and feedback tools are free. " +
151
+ "If a twitterapis tool call fails with an error other than 401/402/409/429 and the user has to work around it, if the user asks for something no twitterapis tool covers, " +
152
+ "if a documented field comes back empty or wrong, or if the user is clearly frustrated with a result, draft a report with twitter_feedback_send (action \"draft\"). " +
153
+ "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\". " +
154
+ "Before drafting a report that a parameter is IGNORED or a field is EMPTY, re-run the call with a distinctive value that could only match if the parameter was honoured, and with the phrase quoted; " +
155
+ "if either comes back on topic the issue is ranking or matching, so title it that way and say what the control showed.";
156
+
157
+ /**
158
+ * Build one server for one caller.
159
+ *
160
+ * @param {object} opts
161
+ * @param {string|undefined} opts.apiKey the caller's key; calls fail clearly without one
162
+ * @param {string} [opts.baseUrl] API origin, default https://api.twitterapis.com
163
+ * @param {number} [opts.timeoutMs] per-request timeout, must be > 0
164
+ * @param {object} [opts.feedbackEnv] env-shaped object for the feedback queue location
165
+ * (TWITTERAPIS_FEEDBACK_DIR); a remote host gives each
166
+ * caller its own directory
167
+ * @param {typeof fetch} [opts.fetchImpl] injectable for tests
168
+ * @param {Record<string,string>} [opts.authHeaders]
169
+ * headers that authenticate each call INSTEAD of the API key. For a host
170
+ * that has already authenticated the caller some other way (an OAuth
171
+ * token resolved to an account) and forwards calls over its own trusted
172
+ * channel. When set, no API key is required or sent.
173
+ */
174
+ export function createServer({
175
+ apiKey,
176
+ baseUrl = DEFAULT_BASE_URL,
177
+ timeoutMs = DEFAULT_TIMEOUT_MS,
178
+ feedbackEnv = process.env,
179
+ fetchImpl = fetch,
180
+ authHeaders = null,
181
+ } = {}) {
182
+ const BASE_URL = String(baseUrl).replace(/\/+$/, "");
183
+ const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
184
+
185
+ // The last tool call that failed, so a feedback draft can carry the endpoint,
186
+ // status and request id without the model retyping them. Set in callEndpoint's
187
+ // error branch; the tool name is added by the registration wrapper below.
188
+ // Per server, so one caller's failure never reaches another caller's draft.
189
+ let lastError = null;
190
+
191
+ // ── REST call ──────────────────────────────────────────────────────────────
192
+ // Most endpoints (GET reads and the simple POST writes alike) read their params
193
+ // from the query string, so the same buildQuery path serves both and only the
194
+ // HTTP method differs. A few POST endpoints (customer/session, user_login,
195
+ // media/upload) instead read a JSON request body; those tools set jsonBody:true
196
+ // and callEndpoint sends the args in the body rather than the query string. A
197
+ // handful of monitoring endpoints (/monitor/{id}, /webhook/{id}, ...) carry a
198
+ // REST path parameter instead: those tools set pathParams (the arg names to
199
+ // substitute into the URL template) and callEndpoint splices them into path
200
+ // before building the query string or body, so a pathParams arg never leaks
201
+ // into either.
202
+ async function callEndpoint(path, args, method = "GET", jsonBody = false, pathParams = []) {
203
+ if (!apiKey && !authHeaders) return paywallResult("no_key");
204
+ // Fill {name} URL segments from args and strip those keys, so a pathParams arg
205
+ // (e.g. a monitor/webhook id) never also leaks into the query string or JSON
206
+ // body. A missing value fails loudly rather than shipping a request that still
207
+ // contains the literal "{id}" against the API.
208
+ let resolvedPath, all;
209
+ try {
210
+ ({ path: resolvedPath, args: all } = resolvePathParams(path, pathParams, args));
211
+ } catch (err) {
212
+ if (err instanceof MissingPathParamError) {
213
+ return { isError: true, content: [{ type: "text", text: err.message }] };
214
+ }
215
+ throw err;
216
+ }
217
+
218
+ const headers = {
219
+ ...(authHeaders
220
+ ? { ...authHeaders }
221
+ : {
222
+ // The API accepts either header; send both for maximum compatibility.
223
+ Authorization: `Bearer ${apiKey}`,
224
+ "x-api-key": apiKey,
225
+ }),
226
+ accept: "application/json",
227
+ "user-agent": `twitterapis-mcp/${VERSION}`,
228
+ };
229
+
230
+ let url;
231
+ let reqBody;
232
+ if (jsonBody) {
233
+ // Endpoints whose handler reads a JSON request body (customer/session,
234
+ // user_login, media/upload). Send every arg in the body: for customer/session
235
+ // and user_login the credentials ARE the payload the handler reads from the
236
+ // body, so they must NOT be diverted into x-* headers the way per-call inline
237
+ // creds are on the query-string tools.
238
+ url = `${BASE_URL}${resolvedPath}`;
239
+ headers["content-type"] = "application/json";
240
+ reqBody = JSON.stringify(all);
241
+ } else {
242
+ // Pull per-call inline credentials out of args so they travel as request
243
+ // headers, never the query string (the API reads x-auth-token / x-ct0; passing
244
+ // them as query params would leak them into URLs and access logs). When
245
+ // supplied, this one API key acts as that account; otherwise the key's linked
246
+ // session is used. Lets a single key act as many accounts.
247
+ const { auth_token, ct0, user_agent, proxy_url, ...rest } = all;
248
+ const q = buildQuery(rest);
249
+ url = `${BASE_URL}${resolvedPath}${q ? `?${q}` : ""}`;
250
+ if (auth_token && ct0) {
251
+ headers["x-auth-token"] = auth_token;
252
+ headers["x-ct0"] = ct0;
253
+ if (user_agent) headers["x-user-agent"] = user_agent;
254
+ if (proxy_url) headers["x-proxy-url"] = proxy_url;
255
+ }
256
+ }
257
+
258
+ const ctrl = new AbortController();
259
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
260
+ try {
261
+ const res = await fetchImpl(url, {
262
+ method,
263
+ headers,
264
+ body: reqBody,
265
+ signal: ctrl.signal,
266
+ });
267
+ const body = await res.text();
268
+ if (!res.ok) {
269
+ const hint = hintFor(res.status, resolvedPath);
270
+ lastError = {
271
+ path: resolvedPath,
272
+ method,
273
+ status: res.status,
274
+ requestId: res.headers.get("x-request-id") || undefined,
275
+ ts: Date.now(),
276
+ };
277
+ // Credential, credit, session, rate-limit and not-found failures are the
278
+ // caller's situation (a 404 is almost always a wrong id), not a product
279
+ // defect; everything else may be one, and the model reads error bodies
280
+ // closely, so the pointer lives here.
281
+ const feedbackHint =
282
+ res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
283
+ ? ""
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)}`);
287
+ return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
288
+ }
289
+ // A success clears the record so a later draft never inherits an old
290
+ // failure's endpoint or request id (review 2026-09-04: a delete's draft
291
+ // carried the previous update's 404).
292
+ lastError = null;
293
+ return { content: [{ type: "text", text: body }] };
294
+ } catch (err) {
295
+ const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
296
+ lastError = { path: resolvedPath, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
297
+ return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
298
+ } finally {
299
+ clearTimeout(timer);
300
+ }
301
+ }
302
+
303
+ const server = new McpServer({ name: "twitterapis", version: VERSION }, { instructions: INSTRUCTIONS });
304
+
305
+ // Handlers for tools that carry local: "<name>" in the catalog. A name the
306
+ // catalog uses and this map lacks is a boot-time failure, never a silent
307
+ // passthrough to the API with the local args attached.
308
+ const LOCAL_HANDLERS = {
309
+ feedback: createFeedbackHandler({
310
+ callEndpoint,
311
+ version: VERSION,
312
+ getClientInfo: () => server.server.getClientVersion(),
313
+ getLastError: () => lastError,
314
+ env: feedbackEnv,
315
+ }),
316
+ };
317
+
318
+ for (const tool of TOOLS) {
319
+ const method = tool.method || "GET";
320
+ // Surface read/write/destructive intent so MCP clients can warn before a
321
+ // mutating call (default = read-only).
322
+ const annotations = {
323
+ title: tool.name,
324
+ readOnlyHint: !tool.write,
325
+ destructiveHint: Boolean(tool.destructive),
326
+ openWorldHint: true,
327
+ };
328
+ let handler;
329
+ if (tool.local) {
330
+ handler = LOCAL_HANDLERS[tool.local];
331
+ if (!handler) throw new Error(`[twitterapis-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/server.js has none`);
332
+ } else {
333
+ handler = async (args) => {
334
+ const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []);
335
+ if (result?.isError && lastError) {
336
+ let resolved = null;
337
+ try { resolved = resolvePathParams(tool.path, tool.pathParams || [], args).path; } catch { resolved = null; }
338
+ // Name the tool only when BOTH method and path match the recorded
339
+ // failure; two tools share /monitor/{id} (POST update, DELETE remove).
340
+ if (resolved === lastError.path && method === lastError.method) lastError.tool = tool.name;
341
+ }
342
+ return result;
343
+ };
344
+ }
345
+ server.registerTool(
346
+ tool.name,
347
+ { description: tool.description, inputSchema: tool.shape, annotations },
348
+ handler,
349
+ );
350
+ }
351
+
352
+ return { server, callEndpoint, getLastError: () => lastError, baseUrl: BASE_URL, timeoutMs: REQUEST_TIMEOUT_MS };
353
+ }