twittertools-mcp 1.1.1 → 1.2.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/package.json +1 -1
  2. package/server.js +39 -7
  3. package/xrules.js +10 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twittertools-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.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",
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.1.1"; // keep in sync with package.json
32
+ const VERSION = "1.2.1"; // 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
@@ -147,17 +152,38 @@ async function callApi(endpoint, params) {
147
152
  return body;
148
153
  }
149
154
 
155
+ // A Snowflake id encodes the creation time, so an id decoding to the future
156
+ // cannot belong to any post — X's endpoint still withholds those like
157
+ // sensitive content. Fail fast (no upstream call) with the real cause instead
158
+ // of relaying a misleading login-wall message for what is likely a typo.
159
+ function impossibleIdError(input) {
160
+ const parsed = parseTweetInput(input);
161
+ if (!parsed) return null;
162
+ const created = snowflakeToDate(parsed.id);
163
+ if (Number.isNaN(created.getTime()) || created.getTime() <= Date.now() + 60_000) return null;
164
+ return textResult(
165
+ `Post not found — this ID encodes a posting date in the future (${created.toISOString()}), so no post can have it. Check the number for typos.`,
166
+ { error: true }
167
+ );
168
+ }
169
+
150
170
  async function callTool(name, args) {
151
171
  switch (name) {
152
172
  case "get_tweet": {
153
- const data = await callApi("tweet", { id: strArg(args, "url_or_id") });
173
+ const input = strArg(args, "url_or_id");
174
+ const impossible = impossibleIdError(input);
175
+ if (impossible) return impossible;
176
+ const data = await callApi("tweet", { id: input });
154
177
  if (!data?.tweet) throw new Error("twittertools API returned no tweet");
155
178
  // attribution rides inside the JSON: clients that pretty-render tool
156
179
  // results would strip a trailing signature line appended after it
157
180
  return textResult(JSON.stringify({ tweet: data.tweet, via: VIA }, null, 2));
158
181
  }
159
182
  case "get_thread": {
160
- const data = await callApi("thread", { url: strArg(args, "url_or_id") });
183
+ const input = strArg(args, "url_or_id");
184
+ const impossible = impossibleIdError(input);
185
+ if (impossible) return impossible;
186
+ const data = await callApi("thread", { url: input });
161
187
  if (!data?.tweets) throw new Error("twittertools API returned no thread");
162
188
  const head = data.partial
163
189
  ? `Note: this thread result is PARTIAL (${data.reason || "incomplete"}) — posts may be missing.\n\n`
@@ -181,6 +207,7 @@ async function callTool(name, args) {
181
207
  JSON.stringify(
182
208
  {
183
209
  id: parsed.id,
210
+ source: parsed.source,
184
211
  screenName: parsed.screenName,
185
212
  createdAt: Number.isNaN(created.getTime()) ? null : created.toISOString(),
186
213
  permalink: permalinkFor(parsed.id, parsed.screenName),
@@ -216,15 +243,20 @@ async function dispatch(msg) {
216
243
  const isRequest = "id" in msg;
217
244
  try {
218
245
  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
246
+ case "initialize": {
247
+ // Answer with a version we actually implement: echoing whatever the
248
+ // client asked for would claim support for versions we've never seen.
249
+ // A client that can't accept our answer disconnects, per spec.
250
+ const requested = params?.protocolVersion;
222
251
  sendResult(id, {
223
- protocolVersion: params?.protocolVersion || "2024-11-05",
252
+ protocolVersion: SUPPORTED_VERSIONS.includes(requested)
253
+ ? requested
254
+ : SUPPORTED_VERSIONS[0],
224
255
  capabilities: { tools: {} },
225
256
  serverInfo: { name: NAME, version: VERSION },
226
257
  });
227
258
  return;
259
+ }
228
260
  case "notifications/initialized":
229
261
  return; // notification — no response
230
262
  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
  }