@twitterapis/mcp 0.13.0 → 0.14.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 (2026-09-29)
4
+
5
+ - **`@twitterapis/mcp/server` export and `authHeaders`.** The package now
6
+ exports `createServer` at `@twitterapis/mcp/server`, so a host can serve the
7
+ same tools remotely. `createServer({ authHeaders })` authenticates each call
8
+ with headers the host supplies instead of an API key, for a host that has
9
+ already authenticated the caller another way (an OAuth token resolved to an
10
+ account). Without it, behavior is unchanged: the API key is sent as before.
11
+ The `exports` map keeps `.` (the stdio entry) and `./package.json`.
12
+
13
+ - **Per-caller server state.** The server is now built by `createServer()` in
14
+ `src/server.js`, which holds the API key and the last-failed-call record in
15
+ its own closure instead of module globals. Under stdio nothing changes (one
16
+ process, one caller); it is the prerequisite for serving the same tools
17
+ remotely, where one process serves many callers and a global would send one
18
+ caller's requests with another's key. `src/index.js` is now only the stdio
19
+ entry and the one place config is read from the environment.
20
+ `test/per-caller-state.test.mjs` proves two servers in one process never
21
+ share a key or a failure record (red when `lastError` is hoisted back to
22
+ module scope).
23
+
24
+ ## 0.13.1 (2026-09-28)
25
+
26
+ - **`twitter_user_search` no longer claims bio matching.** X's People search
27
+ matches display name and handle only; a term that appears only in a bio
28
+ returns no match (measured live 2026-09-28). The description and the `query`
29
+ arg now say so, and point to `twitter_user_followers` or
30
+ `twitter_advanced_search` plus a `description` filter for bio lookups. No
31
+ schema change; `test/openapi.snapshot.json` and the catalog baseline are
32
+ refreshed to the corrected docs SoT.
33
+
3
34
  ## 0.13.0 (2026-09-23)
4
35
 
5
36
  - **Every data read takes `fields` and `compact`.** The API added response
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.0",
4
+ "version": "0.14.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/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",
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,270 @@
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
+ export function hintFor(status, path) {
45
+ if (status === 401) return " (invalid or missing API key, verify TWITTERAPIS_KEY at https://www.twitterapis.com/dashboard)";
46
+ if (status === 402) return " (insufficient credits, top up at https://www.twitterapis.com/dashboard)";
47
+ if (status === 403) return " (access forbidden. The resource may be private or your plan does not include this endpoint)";
48
+ if (status === 404) {
49
+ for (const [re, h] of NOT_FOUND_HINTS) if (re.test(path || "")) return h;
50
+ return DEFAULT_NOT_FOUND_HINT;
51
+ }
52
+ 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)";
53
+ if (status === 429) return " (rate limited. Wait a few seconds and retry; reduce request frequency or increase TWITTERAPIS_TIMEOUT_MS if needed)";
54
+ if (status >= 500) return " (upstream API error. Retry in a moment; if persistent, check https://www.twitterapis.com/status)";
55
+ return "";
56
+ }
57
+
58
+ // Standing instructions the client hands its model alongside the tool list.
59
+ // This is the trigger list for feedback, in the place a model actually reads.
60
+ export const INSTRUCTIONS =
61
+ "twitterapis.com MCP server. Read tools cost credits per call (most $0.0008); account, monitoring and feedback tools are free. " +
62
+ "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, " +
63
+ "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\"). " +
64
+ "Drafting is local and silent; never send a draft unless the user names it after reviewing action \"list\". " +
65
+ "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; " +
66
+ "if either comes back on topic the issue is ranking or matching, so title it that way and say what the control showed.";
67
+
68
+ /**
69
+ * Build one server for one caller.
70
+ *
71
+ * @param {object} opts
72
+ * @param {string|undefined} opts.apiKey the caller's key; calls fail clearly without one
73
+ * @param {string} [opts.baseUrl] API origin, default https://api.twitterapis.com
74
+ * @param {number} [opts.timeoutMs] per-request timeout, must be > 0
75
+ * @param {object} [opts.feedbackEnv] env-shaped object for the feedback queue location
76
+ * (TWITTERAPIS_FEEDBACK_DIR); a remote host gives each
77
+ * caller its own directory
78
+ * @param {typeof fetch} [opts.fetchImpl] injectable for tests
79
+ * @param {Record<string,string>} [opts.authHeaders]
80
+ * headers that authenticate each call INSTEAD of the API key. For a host
81
+ * that has already authenticated the caller some other way (an OAuth
82
+ * token resolved to an account) and forwards calls over its own trusted
83
+ * channel. When set, no API key is required or sent.
84
+ */
85
+ export function createServer({
86
+ apiKey,
87
+ baseUrl = DEFAULT_BASE_URL,
88
+ timeoutMs = DEFAULT_TIMEOUT_MS,
89
+ feedbackEnv = process.env,
90
+ fetchImpl = fetch,
91
+ authHeaders = null,
92
+ } = {}) {
93
+ const BASE_URL = String(baseUrl).replace(/\/+$/, "");
94
+ const REQUEST_TIMEOUT_MS = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
95
+
96
+ // The last tool call that failed, so a feedback draft can carry the endpoint,
97
+ // status and request id without the model retyping them. Set in callEndpoint's
98
+ // error branch; the tool name is added by the registration wrapper below.
99
+ // Per server, so one caller's failure never reaches another caller's draft.
100
+ let lastError = null;
101
+
102
+ // ── REST call ──────────────────────────────────────────────────────────────
103
+ // Most endpoints (GET reads and the simple POST writes alike) read their params
104
+ // from the query string, so the same buildQuery path serves both and only the
105
+ // HTTP method differs. A few POST endpoints (customer/session, user_login,
106
+ // media/upload) instead read a JSON request body; those tools set jsonBody:true
107
+ // and callEndpoint sends the args in the body rather than the query string. A
108
+ // handful of monitoring endpoints (/monitor/{id}, /webhook/{id}, ...) carry a
109
+ // REST path parameter instead: those tools set pathParams (the arg names to
110
+ // substitute into the URL template) and callEndpoint splices them into path
111
+ // before building the query string or body, so a pathParams arg never leaks
112
+ // into either.
113
+ 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
+ }
123
+ // Fill {name} URL segments from args and strip those keys, so a pathParams arg
124
+ // (e.g. a monitor/webhook id) never also leaks into the query string or JSON
125
+ // body. A missing value fails loudly rather than shipping a request that still
126
+ // contains the literal "{id}" against the API.
127
+ let resolvedPath, all;
128
+ try {
129
+ ({ path: resolvedPath, args: all } = resolvePathParams(path, pathParams, args));
130
+ } catch (err) {
131
+ if (err instanceof MissingPathParamError) {
132
+ return { isError: true, content: [{ type: "text", text: err.message }] };
133
+ }
134
+ throw err;
135
+ }
136
+
137
+ const headers = {
138
+ ...(authHeaders
139
+ ? { ...authHeaders }
140
+ : {
141
+ // The API accepts either header; send both for maximum compatibility.
142
+ Authorization: `Bearer ${apiKey}`,
143
+ "x-api-key": apiKey,
144
+ }),
145
+ accept: "application/json",
146
+ "user-agent": `twitterapis-mcp/${VERSION}`,
147
+ };
148
+
149
+ let url;
150
+ let reqBody;
151
+ if (jsonBody) {
152
+ // Endpoints whose handler reads a JSON request body (customer/session,
153
+ // user_login, media/upload). Send every arg in the body: for customer/session
154
+ // and user_login the credentials ARE the payload the handler reads from the
155
+ // body, so they must NOT be diverted into x-* headers the way per-call inline
156
+ // creds are on the query-string tools.
157
+ url = `${BASE_URL}${resolvedPath}`;
158
+ headers["content-type"] = "application/json";
159
+ reqBody = JSON.stringify(all);
160
+ } else {
161
+ // Pull per-call inline credentials out of args so they travel as request
162
+ // headers, never the query string (the API reads x-auth-token / x-ct0; passing
163
+ // them as query params would leak them into URLs and access logs). When
164
+ // supplied, this one API key acts as that account; otherwise the key's linked
165
+ // session is used. Lets a single key act as many accounts.
166
+ const { auth_token, ct0, user_agent, proxy_url, ...rest } = all;
167
+ const q = buildQuery(rest);
168
+ url = `${BASE_URL}${resolvedPath}${q ? `?${q}` : ""}`;
169
+ if (auth_token && ct0) {
170
+ headers["x-auth-token"] = auth_token;
171
+ headers["x-ct0"] = ct0;
172
+ if (user_agent) headers["x-user-agent"] = user_agent;
173
+ if (proxy_url) headers["x-proxy-url"] = proxy_url;
174
+ }
175
+ }
176
+
177
+ const ctrl = new AbortController();
178
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
179
+ try {
180
+ const res = await fetchImpl(url, {
181
+ method,
182
+ headers,
183
+ body: reqBody,
184
+ signal: ctrl.signal,
185
+ });
186
+ const body = await res.text();
187
+ if (!res.ok) {
188
+ const hint = hintFor(res.status, resolvedPath);
189
+ lastError = {
190
+ path: resolvedPath,
191
+ method,
192
+ status: res.status,
193
+ requestId: res.headers.get("x-request-id") || undefined,
194
+ ts: Date.now(),
195
+ };
196
+ // Credential, credit, session, rate-limit and not-found failures are the
197
+ // caller's situation (a 404 is almost always a wrong id), not a product
198
+ // defect; everything else may be one, and the model reads error bodies
199
+ // closely, so the pointer lives here.
200
+ const feedbackHint =
201
+ res.status === 401 || res.status === 402 || res.status === 404 || res.status === 409 || res.status === 429
202
+ ? ""
203
+ : " 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).";
204
+ return { isError: true, content: [{ type: "text", text: `HTTP ${res.status}${hint}: ${body.slice(0, 1200)}${feedbackHint}` }] };
205
+ }
206
+ // A success clears the record so a later draft never inherits an old
207
+ // failure's endpoint or request id (review 2026-09-04: a delete's draft
208
+ // carried the previous update's 404).
209
+ lastError = null;
210
+ return { content: [{ type: "text", text: body }] };
211
+ } catch (err) {
212
+ const msg = err?.name === "AbortError" ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : err?.message || String(err);
213
+ lastError = { path: resolvedPath, method, status: null, error: msg.slice(0, 200), ts: Date.now() };
214
+ return { isError: true, content: [{ type: "text", text: `Request failed: ${msg}` }] };
215
+ } finally {
216
+ clearTimeout(timer);
217
+ }
218
+ }
219
+
220
+ const server = new McpServer({ name: "twitterapis", version: VERSION }, { instructions: INSTRUCTIONS });
221
+
222
+ // Handlers for tools that carry local: "<name>" in the catalog. A name the
223
+ // catalog uses and this map lacks is a boot-time failure, never a silent
224
+ // passthrough to the API with the local args attached.
225
+ const LOCAL_HANDLERS = {
226
+ feedback: createFeedbackHandler({
227
+ callEndpoint,
228
+ version: VERSION,
229
+ getClientInfo: () => server.server.getClientVersion(),
230
+ getLastError: () => lastError,
231
+ env: feedbackEnv,
232
+ }),
233
+ };
234
+
235
+ for (const tool of TOOLS) {
236
+ const method = tool.method || "GET";
237
+ // Surface read/write/destructive intent so MCP clients can warn before a
238
+ // mutating call (default = read-only).
239
+ const annotations = {
240
+ title: tool.name,
241
+ readOnlyHint: !tool.write,
242
+ destructiveHint: Boolean(tool.destructive),
243
+ openWorldHint: true,
244
+ };
245
+ let handler;
246
+ if (tool.local) {
247
+ handler = LOCAL_HANDLERS[tool.local];
248
+ if (!handler) throw new Error(`[twitterapis-mcp] tool ${tool.name} declares local handler "${tool.local}" but src/server.js has none`);
249
+ } else {
250
+ handler = async (args) => {
251
+ const result = await callEndpoint(tool.path, args, method, Boolean(tool.jsonBody), tool.pathParams || []);
252
+ if (result?.isError && lastError) {
253
+ let resolved = null;
254
+ try { resolved = resolvePathParams(tool.path, tool.pathParams || [], args).path; } catch { resolved = null; }
255
+ // Name the tool only when BOTH method and path match the recorded
256
+ // failure; two tools share /monitor/{id} (POST update, DELETE remove).
257
+ if (resolved === lastError.path && method === lastError.method) lastError.tool = tool.name;
258
+ }
259
+ return result;
260
+ };
261
+ }
262
+ server.registerTool(
263
+ tool.name,
264
+ { description: tool.description, inputSchema: tool.shape, annotations },
265
+ handler,
266
+ );
267
+ }
268
+
269
+ return { server, callEndpoint, getLastError: () => lastError, baseUrl: BASE_URL, timeoutMs: REQUEST_TIMEOUT_MS };
270
+ }
package/src/tools.js CHANGED
@@ -61,10 +61,10 @@ export const TOOLS = [
61
61
  name: "twitter_user_search",
62
62
  path: "/twitter/user/search",
63
63
  description:
64
- "Search for Twitter/X user accounts by name, keyword, or topic. Returns matching profiles (username, display name, bio, follower count, verification status) with a pagination cursor. Use this to discover accounts in a niche, find brand handles, or locate a person when you only know their name.",
64
+ "Search for Twitter/X user accounts by display name or handle (X's People search; bio text is NOT searched, so a word that appears only in a bio returns no match). Returns matching profiles (username, display name, bio, follower count, verification status) with a pagination cursor. Use this to find brand handles, resolve a partial handle, or locate a person when you only know their name. To find accounts by what their bio says, use twitter_user_followers on a relevant account or twitter_advanced_search for tweets mentioning it, then filter the description field.",
65
65
  shape: {
66
66
  query: z.string().describe(
67
- "Name, keyword, or topic to search accounts for. Examples: 'OpenAI', 'AI researcher', 'tech founder'.",
67
+ "Name, brand, or partial handle to search accounts for, matched against display name and handle. Examples: 'OpenAI', 'Sam Altman', 'stablecoin'.",
68
68
  ),
69
69
  count: z.number().int().min(1).max(200).optional().describe(
70
70
  "Requested page size, capped at 200. Advisory only for this endpoint: X's own search backend typically returns around 13 to 20 tweets per page regardless of the value requested here, an upstream limit, not something this API controls. To retrieve more results, page with the cursor from the previous response rather than raising this value.",