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.
- package/package.json +1 -1
- package/server.js +39 -7
- package/xrules.js +10 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twittertools-mcp",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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
|
|
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
|
-
//
|
|
221
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
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
|
}
|