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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -4
  3. 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.0.2",
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.0.2"; // keep in sync with package.json
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 ATTRIBUTION = "\n\nvia twittertools.com";
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
- return textResult(JSON.stringify(data.tweet, null, 2) + ATTRIBUTION);
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?.partial
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) + ATTRIBUTION);
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
- const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: {} } });
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
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
206
+ function sendResult(id, result) {
207
+ send({ jsonrpc: "2.0", id, result });
208
+ }
196
209
 
197
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
198
- const { name, arguments: args } = request.params;
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
- return await callTool(name, args);
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
- return textResult(`Error: ${e?.message || String(e)}`, { error: true });
250
+ if (isRequest) sendError(id, -32603, `Internal error: ${e?.message || String(e)}`);
203
251
  }
204
- });
252
+ }
205
253
 
206
- await server.connect(new StdioServerTransport());
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));