twittertools-mcp 1.0.2 → 1.1.1
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/README.md +8 -0
- package/package.json +1 -4
- package/server.js +89 -16
package/README.md
CHANGED
|
@@ -20,6 +20,10 @@ API, which resolves public posts through X's free syndication endpoint. The othe
|
|
|
20
20
|
three run entirely offline. Media is **not** proxied: tweet results carry the
|
|
21
21
|
direct `pbs.twimg.com` / `video.twimg.com` URLs, which any client can fetch on its own.
|
|
22
22
|
|
|
23
|
+
The package has **zero dependencies**: the MCP protocol layer is hand-rolled
|
|
24
|
+
(line-delimited JSON-RPC over stdio), so the entire supply-chain surface is the
|
|
25
|
+
couple hundred lines in this directory — verifiable against Socket or any scanner.
|
|
26
|
+
|
|
23
27
|
## Install
|
|
24
28
|
|
|
25
29
|
```sh
|
|
@@ -43,6 +47,10 @@ Or add it to any client that reads an `mcpServers` config
|
|
|
43
47
|
|
|
44
48
|
Requires Node 18+.
|
|
45
49
|
|
|
50
|
+
> Tip: `npx` resolves the latest version from the registry on every launch
|
|
51
|
+
> (instant once cached, a few seconds the very first time). For zero startup
|
|
52
|
+
> delay, `npm i -g twittertools-mcp` once and set `"command": "twittertools-mcp"`.
|
|
53
|
+
|
|
46
54
|
## Self-hosting
|
|
47
55
|
|
|
48
56
|
Point `TWITTERTOOLS_API_BASE` at your own instance of
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twittertools-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"mcpName": "io.github.steley/twittertools-mcp",
|
|
5
5
|
"description": "MCP server for twittertools.com — fetch X (Twitter) posts and threads, count and split posts with X's real rules. No login, no tracking.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,8 +33,5 @@
|
|
|
33
33
|
"bugs": "https://github.com/steley/twittertools/issues",
|
|
34
34
|
"scripts": {
|
|
35
35
|
"start": "node server.js"
|
|
36
|
-
},
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
39
36
|
}
|
|
40
37
|
}
|
package/server.js
CHANGED
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
* direct pbs.twimg.com / video.twimg.com URLs, which any client can fetch.
|
|
13
13
|
*
|
|
14
14
|
* Set TWITTERTOOLS_API_BASE to point at a self-hosted instance.
|
|
15
|
+
*
|
|
16
|
+
* The MCP protocol layer below is hand-rolled on purpose: the official SDK
|
|
17
|
+
* pulls an HTTP-server dependency tree (express, hono, ajv, cross-spawn, …)
|
|
18
|
+
* into a stdio-only package. A tools-only server needs exactly four verbs,
|
|
19
|
+
* implemented against the line-delimited JSON-RPC 2.0 framing of the MCP
|
|
20
|
+
* stdio transport — pinned by the vitest suite in test/.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
20
23
|
import {
|
|
21
24
|
countTweet,
|
|
22
25
|
splitThread,
|
|
@@ -26,9 +29,9 @@ import {
|
|
|
26
29
|
} from "./xrules.js";
|
|
27
30
|
|
|
28
31
|
const NAME = "twittertools";
|
|
29
|
-
const VERSION = "1.
|
|
32
|
+
const VERSION = "1.1.1"; // keep in sync with package.json
|
|
30
33
|
const API_BASE = (process.env.TWITTERTOOLS_API_BASE || "https://twittertools.com").replace(/\/+$/, "");
|
|
31
|
-
const
|
|
34
|
+
const VIA = "https://twittertools.com";
|
|
32
35
|
const TEXT_INPUT_MAX = 100_000; // generous, but caps local work per call
|
|
33
36
|
|
|
34
37
|
const urlOrIdSchema = {
|
|
@@ -149,15 +152,17 @@ async function callTool(name, args) {
|
|
|
149
152
|
case "get_tweet": {
|
|
150
153
|
const data = await callApi("tweet", { id: strArg(args, "url_or_id") });
|
|
151
154
|
if (!data?.tweet) throw new Error("twittertools API returned no tweet");
|
|
152
|
-
|
|
155
|
+
// attribution rides inside the JSON: clients that pretty-render tool
|
|
156
|
+
// results would strip a trailing signature line appended after it
|
|
157
|
+
return textResult(JSON.stringify({ tweet: data.tweet, via: VIA }, null, 2));
|
|
153
158
|
}
|
|
154
159
|
case "get_thread": {
|
|
155
160
|
const data = await callApi("thread", { url: strArg(args, "url_or_id") });
|
|
156
161
|
if (!data?.tweets) throw new Error("twittertools API returned no thread");
|
|
157
|
-
const head = data
|
|
162
|
+
const head = data.partial
|
|
158
163
|
? `Note: this thread result is PARTIAL (${data.reason || "incomplete"}) — posts may be missing.\n\n`
|
|
159
164
|
: "";
|
|
160
|
-
return textResult(head + JSON.stringify({ count: data.tweets.length, tweets: data.tweets }, null, 2)
|
|
165
|
+
return textResult(head + JSON.stringify({ count: data.tweets.length, tweets: data.tweets, via: VIA }, null, 2));
|
|
161
166
|
}
|
|
162
167
|
case "count_chars":
|
|
163
168
|
return textResult(JSON.stringify(countTweet(strArg(args, "text", TEXT_INPUT_MAX)), null, 2));
|
|
@@ -190,17 +195,85 @@ async function callTool(name, args) {
|
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
|
|
193
|
-
|
|
198
|
+
// --------------------------------------------------------------------------- //
|
|
199
|
+
// Line-delimited JSON-RPC 2.0 over stdio (MCP stdio transport) //
|
|
200
|
+
// --------------------------------------------------------------------------- //
|
|
201
|
+
|
|
202
|
+
function send(msg) {
|
|
203
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
204
|
+
}
|
|
194
205
|
|
|
195
|
-
|
|
206
|
+
function sendResult(id, result) {
|
|
207
|
+
send({ jsonrpc: "2.0", id, result });
|
|
208
|
+
}
|
|
196
209
|
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
function sendError(id, code, message) {
|
|
211
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function dispatch(msg) {
|
|
215
|
+
const { method, id, params } = msg;
|
|
216
|
+
const isRequest = "id" in msg;
|
|
199
217
|
try {
|
|
200
|
-
|
|
218
|
+
switch (method) {
|
|
219
|
+
case "initialize":
|
|
220
|
+
// echo the client's requested version: every current client accepts
|
|
221
|
+
// its own, and we make no use of newer protocol features
|
|
222
|
+
sendResult(id, {
|
|
223
|
+
protocolVersion: params?.protocolVersion || "2024-11-05",
|
|
224
|
+
capabilities: { tools: {} },
|
|
225
|
+
serverInfo: { name: NAME, version: VERSION },
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
case "notifications/initialized":
|
|
229
|
+
return; // notification — no response
|
|
230
|
+
case "tools/list":
|
|
231
|
+
sendResult(id, { tools: TOOLS });
|
|
232
|
+
return;
|
|
233
|
+
case "tools/call":
|
|
234
|
+
// tool execution failures are tool results (isError), not protocol
|
|
235
|
+
// errors — matches how every client renders a failed tool call
|
|
236
|
+
try {
|
|
237
|
+
sendResult(id, await callTool(params?.name, params?.arguments));
|
|
238
|
+
} catch (e) {
|
|
239
|
+
sendResult(id, textResult(`Error: ${e?.message || String(e)}`, { error: true }));
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
case "ping":
|
|
243
|
+
sendResult(id, {});
|
|
244
|
+
return;
|
|
245
|
+
default:
|
|
246
|
+
// unknown notifications are silently ignored per JSON-RPC 2.0
|
|
247
|
+
if (isRequest) sendError(id, -32601, `Method not found: ${method}`);
|
|
248
|
+
}
|
|
201
249
|
} catch (e) {
|
|
202
|
-
|
|
250
|
+
if (isRequest) sendError(id, -32603, `Internal error: ${e?.message || String(e)}`);
|
|
203
251
|
}
|
|
204
|
-
}
|
|
252
|
+
}
|
|
205
253
|
|
|
206
|
-
|
|
254
|
+
let buffer = "";
|
|
255
|
+
process.stdin.setEncoding("utf8");
|
|
256
|
+
process.stdin.on("data", (chunk) => {
|
|
257
|
+
buffer += chunk;
|
|
258
|
+
let i;
|
|
259
|
+
while ((i = buffer.indexOf("\n")) >= 0) {
|
|
260
|
+
const line = buffer.slice(0, i).trim();
|
|
261
|
+
buffer = buffer.slice(i + 1);
|
|
262
|
+
if (!line) continue;
|
|
263
|
+
let msg;
|
|
264
|
+
try {
|
|
265
|
+
msg = JSON.parse(line);
|
|
266
|
+
} catch {
|
|
267
|
+
sendError(null, -32700, "Parse error");
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (!msg || typeof msg !== "object" || typeof msg.method !== "string") {
|
|
271
|
+
if (msg && typeof msg === "object" && "id" in msg) {
|
|
272
|
+
sendError(msg.id, -32600, "Invalid Request");
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
dispatch(msg);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
process.stdin.on("end", () => process.exit(0));
|