twittertools-mcp 1.0.2 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -4
  3. package/server.js +84 -13
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twittertools-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
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,7 +29,7 @@ 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.0"; // keep in sync with package.json
30
33
  const API_BASE = (process.env.TWITTERTOOLS_API_BASE || "https://twittertools.com").replace(/\/+$/, "");
31
34
  const ATTRIBUTION = "\n\nvia twittertools.com";
32
35
  const TEXT_INPUT_MAX = 100_000; // generous, but caps local work per call
@@ -154,7 +157,7 @@ async function callTool(name, args) {
154
157
  case "get_thread": {
155
158
  const data = await callApi("thread", { url: strArg(args, "url_or_id") });
156
159
  if (!data?.tweets) throw new Error("twittertools API returned no thread");
157
- const head = data?.partial
160
+ const head = data.partial
158
161
  ? `Note: this thread result is PARTIAL (${data.reason || "incomplete"}) — posts may be missing.\n\n`
159
162
  : "";
160
163
  return textResult(head + JSON.stringify({ count: data.tweets.length, tweets: data.tweets }, null, 2) + ATTRIBUTION);
@@ -190,17 +193,85 @@ async function callTool(name, args) {
190
193
  }
191
194
  }
192
195
 
193
- const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: {} } });
196
+ // --------------------------------------------------------------------------- //
197
+ // Line-delimited JSON-RPC 2.0 over stdio (MCP stdio transport) //
198
+ // --------------------------------------------------------------------------- //
199
+
200
+ function send(msg) {
201
+ process.stdout.write(JSON.stringify(msg) + "\n");
202
+ }
194
203
 
195
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
204
+ function sendResult(id, result) {
205
+ send({ jsonrpc: "2.0", id, result });
206
+ }
196
207
 
197
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
198
- const { name, arguments: args } = request.params;
208
+ function sendError(id, code, message) {
209
+ send({ jsonrpc: "2.0", id, error: { code, message } });
210
+ }
211
+
212
+ async function dispatch(msg) {
213
+ const { method, id, params } = msg;
214
+ const isRequest = "id" in msg;
199
215
  try {
200
- return await callTool(name, args);
216
+ switch (method) {
217
+ case "initialize":
218
+ // echo the client's requested version: every current client accepts
219
+ // its own, and we make no use of newer protocol features
220
+ sendResult(id, {
221
+ protocolVersion: params?.protocolVersion || "2024-11-05",
222
+ capabilities: { tools: {} },
223
+ serverInfo: { name: NAME, version: VERSION },
224
+ });
225
+ return;
226
+ case "notifications/initialized":
227
+ return; // notification — no response
228
+ case "tools/list":
229
+ sendResult(id, { tools: TOOLS });
230
+ return;
231
+ case "tools/call":
232
+ // tool execution failures are tool results (isError), not protocol
233
+ // errors — matches how every client renders a failed tool call
234
+ try {
235
+ sendResult(id, await callTool(params?.name, params?.arguments));
236
+ } catch (e) {
237
+ sendResult(id, textResult(`Error: ${e?.message || String(e)}`, { error: true }));
238
+ }
239
+ return;
240
+ case "ping":
241
+ sendResult(id, {});
242
+ return;
243
+ default:
244
+ // unknown notifications are silently ignored per JSON-RPC 2.0
245
+ if (isRequest) sendError(id, -32601, `Method not found: ${method}`);
246
+ }
201
247
  } catch (e) {
202
- return textResult(`Error: ${e?.message || String(e)}`, { error: true });
248
+ if (isRequest) sendError(id, -32603, `Internal error: ${e?.message || String(e)}`);
203
249
  }
204
- });
250
+ }
205
251
 
206
- await server.connect(new StdioServerTransport());
252
+ let buffer = "";
253
+ process.stdin.setEncoding("utf8");
254
+ process.stdin.on("data", (chunk) => {
255
+ buffer += chunk;
256
+ let i;
257
+ while ((i = buffer.indexOf("\n")) >= 0) {
258
+ const line = buffer.slice(0, i).trim();
259
+ buffer = buffer.slice(i + 1);
260
+ if (!line) continue;
261
+ let msg;
262
+ try {
263
+ msg = JSON.parse(line);
264
+ } catch {
265
+ sendError(null, -32700, "Parse error");
266
+ continue;
267
+ }
268
+ if (!msg || typeof msg !== "object" || typeof msg.method !== "string") {
269
+ if (msg && typeof msg === "object" && "id" in msg) {
270
+ sendError(msg.id, -32600, "Invalid Request");
271
+ }
272
+ continue;
273
+ }
274
+ dispatch(msg);
275
+ }
276
+ });
277
+ process.stdin.on("end", () => process.exit(0));