@twitterapis/mcp 0.13.1 → 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 +21 -0
- package/package.json +7 -2
- package/src/index.js +19 -227
- package/src/server.js +270 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
3
24
|
## 0.13.1 (2026-09-28)
|
|
4
25
|
|
|
5
26
|
- **`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.
|
|
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/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",
|
|
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
|
|
10
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
24
|
-
|
|
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
|
|
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 ${
|
|
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
|
+
}
|