twittertools-mcp 1.2.0 → 1.2.2

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/package.json +1 -1
  2. package/server.js +23 -9
  3. package/xrules.js +10 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twittertools-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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",
package/server.js CHANGED
@@ -29,7 +29,12 @@ import {
29
29
  } from "./xrules.js";
30
30
 
31
31
  const NAME = "twittertools";
32
- const VERSION = "1.2.0"; // keep in sync with package.json
32
+ const VERSION = "1.2.2"; // keep in sync with package.json
33
+ // Protocol versions this server can honestly claim: across these revisions the
34
+ // four verbs used here (initialize, tools/list, tools/call, ping) kept their
35
+ // shapes, and the additions (icons, tasks, elicitation, …) are all optional
36
+ // features a tools-only stdio server never touches.
37
+ const SUPPORTED_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
33
38
  const API_BASE = (process.env.TWITTERTOOLS_API_BASE || "https://twittertools.com").replace(/\/+$/, "");
34
39
  const VIA = "https://twittertools.com";
35
40
  const TEXT_INPUT_MAX = 100_000; // generous, but caps local work per call
@@ -51,15 +56,18 @@ const TOOLS = [
51
56
  description:
52
57
  "Fetch one public X (Twitter) post by URL or ID: text, author, engagement counts, " +
53
58
  "and media with direct CDN URLs (photos, or MP4 video variants sorted by quality). " +
54
- "Deleted, protected, or age-restricted posts are not available without an X login.",
59
+ "Use for a specific post the user links or cites — not for searching X, timelines, " +
60
+ "bookmarks, or DMs. Deleted, protected, or age-restricted posts are not available " +
61
+ "without an X login.",
55
62
  inputSchema: urlOrIdSchema,
56
63
  },
57
64
  {
58
65
  name: "get_thread",
59
66
  description:
60
- "Unroll a public X thread into its posts in order. Expensive upstream — call sparingly, " +
61
- "and prefer pasting the thread's LAST post, which returns the whole chain. Results may " +
62
- "be partial; the response says so when they are.",
67
+ "Unroll a public X thread into its posts in order. For a single post, use get_tweet " +
68
+ "instead. Expensive upstream — call sparingly, and prefer pasting the thread's LAST " +
69
+ "post, which returns the whole chain. Results may be partial; the response says so " +
70
+ "when they are.",
63
71
  inputSchema: urlOrIdSchema,
64
72
  },
65
73
  {
@@ -202,6 +210,7 @@ async function callTool(name, args) {
202
210
  JSON.stringify(
203
211
  {
204
212
  id: parsed.id,
213
+ source: parsed.source,
205
214
  screenName: parsed.screenName,
206
215
  createdAt: Number.isNaN(created.getTime()) ? null : created.toISOString(),
207
216
  permalink: permalinkFor(parsed.id, parsed.screenName),
@@ -237,15 +246,20 @@ async function dispatch(msg) {
237
246
  const isRequest = "id" in msg;
238
247
  try {
239
248
  switch (method) {
240
- case "initialize":
241
- // echo the client's requested version: every current client accepts
242
- // its own, and we make no use of newer protocol features
249
+ case "initialize": {
250
+ // Answer with a version we actually implement: echoing whatever the
251
+ // client asked for would claim support for versions we've never seen.
252
+ // A client that can't accept our answer disconnects, per spec.
253
+ const requested = params?.protocolVersion;
243
254
  sendResult(id, {
244
- protocolVersion: params?.protocolVersion || "2024-11-05",
255
+ protocolVersion: SUPPORTED_VERSIONS.includes(requested)
256
+ ? requested
257
+ : SUPPORTED_VERSIONS[0],
245
258
  capabilities: { tools: {} },
246
259
  serverInfo: { name: NAME, version: VERSION },
247
260
  });
248
261
  return;
262
+ }
249
263
  case "notifications/initialized":
250
264
  return; // notification — no response
251
265
  case "tools/list":
package/xrules.js CHANGED
@@ -217,19 +217,23 @@ const STATUS_RE =
217
217
  /(?:^|[^A-Za-z0-9-])(?:https?:\/\/)?(?:www\.|mobile\.)?(?:x|twitter)\.com\/([A-Za-z0-9_]{1,15})\/status(?:es)?\/(\d{5,25})/i;
218
218
  const BARE_ID_RE = /^\d{5,25}$/;
219
219
 
220
- /** Extract a status id (and screen name when present) from a URL, bare ID, or free text. */
220
+ /** Extract a status id (and screen name when present) from a URL, bare ID, or free
221
+ * text. `source` records which form matched — the one field the standalone port
222
+ * adds beyond the TypeScript original. */
221
223
  export function parseTweetInput(input) {
222
224
  const text = (input ?? '').trim();
223
225
  if (!text) return null;
224
226
 
225
227
  const m = text.match(STATUS_RE);
226
- if (m) return { id: m[2], screenName: m[1].toLowerCase() === 'i' ? null : m[1] };
228
+ if (m) return { id: m[2], screenName: m[1].toLowerCase() === 'i' ? null : m[1], source: 'url' };
227
229
 
228
- if (BARE_ID_RE.test(text)) return { id: text, screenName: null };
230
+ if (BARE_ID_RE.test(text)) return { id: text, screenName: null, source: 'bare_id' };
229
231
 
230
- // A bare ID hidden inside longer text
231
- const idMatch = text.match(/(\d{15,25})/);
232
- if (idMatch) return { id: idMatch[1], screenName: null };
232
+ // A bare ID hidden inside longer text. Both edges are digit-guarded so a
233
+ // longer digit run (order number, hash) yields nothing instead of a
234
+ // silently clipped pseudo-ID.
235
+ const idMatch = text.match(/(?<!\d)(\d{15,25})(?!\d)/);
236
+ if (idMatch) return { id: idMatch[1], screenName: null, source: 'text' };
233
237
 
234
238
  return null;
235
239
  }