twitterapi-io-mcp 1.0.9
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/LICENSE +21 -0
- package/README.md +198 -0
- package/data/docs.json +6240 -0
- package/index.js +2468 -0
- package/package.json +55 -0
package/index.js
ADDED
|
@@ -0,0 +1,2468 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* TwitterAPI.io Documentation MCP Server v1.0.9
|
|
4
|
+
*
|
|
5
|
+
* Production-ready MCP server with:
|
|
6
|
+
* - Comprehensive error handling with ErrorType classification
|
|
7
|
+
* - Input validation for all tools
|
|
8
|
+
* - Structured logging with metrics
|
|
9
|
+
* - LLM-optimized tool descriptions with output schemas
|
|
10
|
+
* - Performance monitoring with SLO tracking
|
|
11
|
+
* - Hybrid cache (memory + disk) for search and endpoints
|
|
12
|
+
* - MCP Resources for static guide access
|
|
13
|
+
* - Data freshness monitoring
|
|
14
|
+
*
|
|
15
|
+
* v3.3 Improvements (Phase 2):
|
|
16
|
+
* - max_results parameter for search (1-20, default 10)
|
|
17
|
+
* - Advanced tokenization with camelCase support
|
|
18
|
+
* - Per-tool latency SLO tracking with alerts
|
|
19
|
+
* - Enhanced MCP Resources for static guides
|
|
20
|
+
* - Data freshness monitoring (24h staleness warning)
|
|
21
|
+
*
|
|
22
|
+
* v3.2 Improvements:
|
|
23
|
+
* - Output schemas for all tools (helps LLM parse responses)
|
|
24
|
+
*
|
|
25
|
+
* v3.1 Improvements:
|
|
26
|
+
* - HybridCache with LRU eviction and TTL expiry
|
|
27
|
+
* - Memory-first caching with disk persistence for stdio MCP
|
|
28
|
+
* - Automatic hourly cache cleanup
|
|
29
|
+
* - Cache stats in metrics resource
|
|
30
|
+
*
|
|
31
|
+
* v3.0 Improvements:
|
|
32
|
+
* - Error handling with suggestions for LLM
|
|
33
|
+
* - Input validation (query length, pattern matching)
|
|
34
|
+
* - Structured logging with latency tracking
|
|
35
|
+
* - Better tool descriptions for LLM decision making
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
39
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
40
|
+
import {
|
|
41
|
+
CallToolRequestSchema,
|
|
42
|
+
CompleteRequestSchema,
|
|
43
|
+
ListResourcesRequestSchema,
|
|
44
|
+
ListToolsRequestSchema,
|
|
45
|
+
ReadResourceRequestSchema,
|
|
46
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
47
|
+
import fs from "fs";
|
|
48
|
+
import path from "path";
|
|
49
|
+
import { fileURLToPath } from "url";
|
|
50
|
+
|
|
51
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const DOCS_PATH = path.join(__dirname, "data", "docs.json");
|
|
53
|
+
|
|
54
|
+
// ========== ERROR HANDLING ==========
|
|
55
|
+
const ErrorType = {
|
|
56
|
+
INPUT_VALIDATION: 'input_validation',
|
|
57
|
+
NOT_FOUND: 'not_found',
|
|
58
|
+
INTERNAL_ERROR: 'internal_error',
|
|
59
|
+
TIMEOUT: 'timeout',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function formatToolError(error) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{
|
|
65
|
+
type: 'text',
|
|
66
|
+
text: `Error: ${error.message}${error.suggestion ? '\n\nSuggestion: ' + error.suggestion : ''}`
|
|
67
|
+
}],
|
|
68
|
+
isError: true
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatToolSuccess(text, structuredContent) {
|
|
73
|
+
const result = {
|
|
74
|
+
content: [{ type: 'text', text }],
|
|
75
|
+
isError: false
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (structuredContent !== undefined) {
|
|
79
|
+
result.structuredContent = structuredContent;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ========== STRUCTURED LOGGING ==========
|
|
86
|
+
const LogLevel = {
|
|
87
|
+
DEBUG: 'DEBUG',
|
|
88
|
+
INFO: 'INFO',
|
|
89
|
+
WARN: 'WARN',
|
|
90
|
+
ERROR: 'ERROR'
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Service Level Objectives (SLOs) - latency targets in ms
|
|
94
|
+
const SLO = {
|
|
95
|
+
search_twitterapi_docs: { target: 50, acceptable: 100, alert: 200 },
|
|
96
|
+
get_twitterapi_endpoint: { target: 10, acceptable: 50, alert: 100 },
|
|
97
|
+
list_twitterapi_endpoints: { target: 5, acceptable: 20, alert: 50 },
|
|
98
|
+
get_twitterapi_guide: { target: 10, acceptable: 50, alert: 100 },
|
|
99
|
+
get_twitterapi_pricing: { target: 5, acceptable: 20, alert: 50 },
|
|
100
|
+
get_twitterapi_auth: { target: 5, acceptable: 20, alert: 50 },
|
|
101
|
+
get_twitterapi_url: { target: 20, acceptable: 200, alert: 1000 }
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
class Logger {
|
|
105
|
+
constructor() {
|
|
106
|
+
this.logs = [];
|
|
107
|
+
this.MAX_LOGS = 10000;
|
|
108
|
+
this.metrics = {
|
|
109
|
+
requests: { total: 0, successful: 0, failed: 0, totalLatency: 0 },
|
|
110
|
+
cache: { hits: 0, misses: 0 },
|
|
111
|
+
tools: {},
|
|
112
|
+
sloViolations: { target: 0, acceptable: 0, alert: 0 }
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
log(level, component, message, data = null) {
|
|
117
|
+
const entry = {
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
level,
|
|
120
|
+
component,
|
|
121
|
+
message,
|
|
122
|
+
data
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
this.logs.push(entry);
|
|
126
|
+
if (this.logs.length > this.MAX_LOGS) {
|
|
127
|
+
this.logs.shift();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Output to stderr (MCP standard - stdout is for protocol)
|
|
131
|
+
const prefix = `[${entry.timestamp}] [${level}] [${component}]`;
|
|
132
|
+
console.error(`${prefix} ${message}`, data ? JSON.stringify(data) : '');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
info(component, message, data) {
|
|
136
|
+
this.log(LogLevel.INFO, component, message, data);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
warn(component, message, data) {
|
|
140
|
+
this.log(LogLevel.WARN, component, message, data);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
error(component, message, error) {
|
|
144
|
+
this.log(LogLevel.ERROR, component, message, {
|
|
145
|
+
error: error?.message,
|
|
146
|
+
stack: error?.stack?.split('\n').slice(0, 3)
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
recordToolCall(toolName, duration, success) {
|
|
151
|
+
this.metrics.requests.total++;
|
|
152
|
+
this.metrics.requests.totalLatency += duration;
|
|
153
|
+
|
|
154
|
+
if (success) {
|
|
155
|
+
this.metrics.requests.successful++;
|
|
156
|
+
} else {
|
|
157
|
+
this.metrics.requests.failed++;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!this.metrics.tools[toolName]) {
|
|
161
|
+
this.metrics.tools[toolName] = {
|
|
162
|
+
calls: 0,
|
|
163
|
+
errors: 0,
|
|
164
|
+
totalDuration: 0,
|
|
165
|
+
minLatency: Infinity,
|
|
166
|
+
maxLatency: 0,
|
|
167
|
+
sloViolations: { target: 0, acceptable: 0, alert: 0 }
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tool = this.metrics.tools[toolName];
|
|
172
|
+
tool.calls++;
|
|
173
|
+
tool.totalDuration += duration;
|
|
174
|
+
tool.minLatency = Math.min(tool.minLatency, duration);
|
|
175
|
+
tool.maxLatency = Math.max(tool.maxLatency, duration);
|
|
176
|
+
|
|
177
|
+
if (!success) {
|
|
178
|
+
tool.errors++;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Track SLO violations
|
|
182
|
+
const slo = SLO[toolName];
|
|
183
|
+
if (slo) {
|
|
184
|
+
if (duration > slo.alert) {
|
|
185
|
+
tool.sloViolations.alert++;
|
|
186
|
+
this.metrics.sloViolations.alert++;
|
|
187
|
+
this.warn('slo', `ALERT: ${toolName} exceeded alert threshold`, {
|
|
188
|
+
duration,
|
|
189
|
+
threshold: slo.alert,
|
|
190
|
+
severity: 'alert'
|
|
191
|
+
});
|
|
192
|
+
} else if (duration > slo.acceptable) {
|
|
193
|
+
tool.sloViolations.acceptable++;
|
|
194
|
+
this.metrics.sloViolations.acceptable++;
|
|
195
|
+
this.warn('slo', `${toolName} exceeded acceptable threshold`, {
|
|
196
|
+
duration,
|
|
197
|
+
threshold: slo.acceptable,
|
|
198
|
+
severity: 'acceptable'
|
|
199
|
+
});
|
|
200
|
+
} else if (duration > slo.target) {
|
|
201
|
+
tool.sloViolations.target++;
|
|
202
|
+
this.metrics.sloViolations.target++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
recordCacheHit() {
|
|
208
|
+
this.metrics.cache.hits++;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
recordCacheMiss() {
|
|
212
|
+
this.metrics.cache.misses++;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
getMetrics(cacheStats = null, dataFreshness = null) {
|
|
216
|
+
const avgLatency = this.metrics.requests.total > 0
|
|
217
|
+
? Math.round(this.metrics.requests.totalLatency / this.metrics.requests.total)
|
|
218
|
+
: 0;
|
|
219
|
+
|
|
220
|
+
const cacheTotal = this.metrics.cache.hits + this.metrics.cache.misses;
|
|
221
|
+
const cacheHitRate = cacheTotal > 0
|
|
222
|
+
? (this.metrics.cache.hits / cacheTotal * 100).toFixed(1)
|
|
223
|
+
: 0;
|
|
224
|
+
|
|
225
|
+
const result = {
|
|
226
|
+
timestamp: new Date().toISOString(),
|
|
227
|
+
uptime: process.uptime(),
|
|
228
|
+
requests: {
|
|
229
|
+
...this.metrics.requests,
|
|
230
|
+
averageLatency: avgLatency
|
|
231
|
+
},
|
|
232
|
+
cache: {
|
|
233
|
+
...this.metrics.cache,
|
|
234
|
+
hitRate: `${cacheHitRate}%`
|
|
235
|
+
},
|
|
236
|
+
sloViolations: this.metrics.sloViolations,
|
|
237
|
+
tools: Object.entries(this.metrics.tools).reduce((acc, [tool, data]) => {
|
|
238
|
+
const slo = SLO[tool];
|
|
239
|
+
acc[tool] = {
|
|
240
|
+
calls: data.calls,
|
|
241
|
+
errors: data.errors,
|
|
242
|
+
latency: {
|
|
243
|
+
avg: Math.round(data.totalDuration / data.calls),
|
|
244
|
+
min: data.minLatency === Infinity ? 0 : data.minLatency,
|
|
245
|
+
max: data.maxLatency
|
|
246
|
+
},
|
|
247
|
+
slo: slo ? {
|
|
248
|
+
target: `${slo.target}ms`,
|
|
249
|
+
acceptable: `${slo.acceptable}ms`,
|
|
250
|
+
alert: `${slo.alert}ms`,
|
|
251
|
+
violations: data.sloViolations
|
|
252
|
+
} : null
|
|
253
|
+
};
|
|
254
|
+
return acc;
|
|
255
|
+
}, {})
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Add hybrid cache stats if provided
|
|
259
|
+
if (cacheStats) {
|
|
260
|
+
result.hybridCaches = cacheStats;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Add data freshness if provided
|
|
264
|
+
if (dataFreshness) {
|
|
265
|
+
result.dataFreshness = dataFreshness;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const logger = new Logger();
|
|
273
|
+
|
|
274
|
+
// ========== HYBRID CACHE ==========
|
|
275
|
+
const CACHE_DIR = path.join(__dirname, "cache");
|
|
276
|
+
|
|
277
|
+
class HybridCache {
|
|
278
|
+
constructor(name, options = {}) {
|
|
279
|
+
this.name = name;
|
|
280
|
+
this.memory = new Map();
|
|
281
|
+
this.MAX_MEMORY = options.maxEntries || 500;
|
|
282
|
+
this.DEFAULT_TTL = options.ttl || 24 * 60 * 60 * 1000; // 24 hours
|
|
283
|
+
this.DISK_WRITE_PROBABILITY = options.diskWriteProbability || 0.1; // 10% disk writes
|
|
284
|
+
this.diskDir = path.join(CACHE_DIR, name);
|
|
285
|
+
this.ensureDir();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
ensureDir() {
|
|
289
|
+
try {
|
|
290
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
291
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
292
|
+
}
|
|
293
|
+
if (!fs.existsSync(this.diskDir)) {
|
|
294
|
+
fs.mkdirSync(this.diskDir, { recursive: true });
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
logger.warn('cache', `Failed to create cache directory: ${err.message}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
normalizeKey(key) {
|
|
302
|
+
return key.toLowerCase().replace(/[^a-z0-9]/g, '_').slice(0, 100);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
isExpired(entry) {
|
|
306
|
+
return Date.now() - entry.timestamp > entry.ttl;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
get(key) {
|
|
310
|
+
const normalized = this.normalizeKey(key);
|
|
311
|
+
|
|
312
|
+
// Check memory first
|
|
313
|
+
const memEntry = this.memory.get(normalized);
|
|
314
|
+
if (memEntry && !this.isExpired(memEntry)) {
|
|
315
|
+
logger.recordCacheHit();
|
|
316
|
+
return memEntry.value;
|
|
317
|
+
}
|
|
318
|
+
this.memory.delete(normalized);
|
|
319
|
+
|
|
320
|
+
// Check disk
|
|
321
|
+
try {
|
|
322
|
+
const diskPath = path.join(this.diskDir, `${normalized}.json`);
|
|
323
|
+
if (fs.existsSync(diskPath)) {
|
|
324
|
+
const diskEntry = JSON.parse(fs.readFileSync(diskPath, 'utf-8'));
|
|
325
|
+
if (!this.isExpired(diskEntry)) {
|
|
326
|
+
// Restore to memory
|
|
327
|
+
this.memory.set(normalized, diskEntry);
|
|
328
|
+
logger.recordCacheHit();
|
|
329
|
+
logger.info('cache', `Restored from disk: ${this.name}/${normalized}`);
|
|
330
|
+
return diskEntry.value;
|
|
331
|
+
}
|
|
332
|
+
// Clean up expired disk entry
|
|
333
|
+
fs.unlinkSync(diskPath);
|
|
334
|
+
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// Disk read failed, continue gracefully
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
logger.recordCacheMiss();
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
set(key, value, ttl = this.DEFAULT_TTL) {
|
|
344
|
+
const normalized = this.normalizeKey(key);
|
|
345
|
+
|
|
346
|
+
const entry = {
|
|
347
|
+
key: normalized,
|
|
348
|
+
value,
|
|
349
|
+
timestamp: Date.now(),
|
|
350
|
+
ttl
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Store in memory
|
|
354
|
+
this.memory.set(normalized, entry);
|
|
355
|
+
logger.info('cache', `Memory write: ${this.name}/${normalized}`, {
|
|
356
|
+
diskProb: this.DISK_WRITE_PROBABILITY
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Evict oldest if over capacity (LRU-like)
|
|
360
|
+
if (this.memory.size > this.MAX_MEMORY) {
|
|
361
|
+
const oldestKey = this.memory.keys().next().value;
|
|
362
|
+
this.memory.delete(oldestKey);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Write to disk (always for stdio MCP servers)
|
|
366
|
+
if (Math.random() < this.DISK_WRITE_PROBABILITY) {
|
|
367
|
+
this.writeToDisk(normalized, entry);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
writeToDisk(key, entry) {
|
|
372
|
+
try {
|
|
373
|
+
const diskPath = path.join(this.diskDir, `${key}.json`);
|
|
374
|
+
fs.writeFileSync(diskPath, JSON.stringify(entry, null, 2));
|
|
375
|
+
logger.info('cache', `Disk write success: ${this.name}/${key}`);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
logger.warn('cache', `Disk write failed: ${err.message}`, { path: this.diskDir, key });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
cleanup() {
|
|
382
|
+
let memoryCleared = 0;
|
|
383
|
+
let diskCleared = 0;
|
|
384
|
+
|
|
385
|
+
// Memory cleanup
|
|
386
|
+
for (const [key, entry] of this.memory.entries()) {
|
|
387
|
+
if (this.isExpired(entry)) {
|
|
388
|
+
this.memory.delete(key);
|
|
389
|
+
memoryCleared++;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Disk cleanup
|
|
394
|
+
try {
|
|
395
|
+
const files = fs.readdirSync(this.diskDir);
|
|
396
|
+
for (const file of files) {
|
|
397
|
+
try {
|
|
398
|
+
const diskPath = path.join(this.diskDir, file);
|
|
399
|
+
const entry = JSON.parse(fs.readFileSync(diskPath, 'utf-8'));
|
|
400
|
+
if (this.isExpired(entry)) {
|
|
401
|
+
fs.unlinkSync(diskPath);
|
|
402
|
+
diskCleared++;
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
// Skip invalid files
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
// Disk cleanup failed, continue
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (memoryCleared > 0 || diskCleared > 0) {
|
|
413
|
+
logger.info('cache', `Cleanup: ${memoryCleared} memory, ${diskCleared} disk entries removed`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
stats() {
|
|
418
|
+
let diskEntries = 0;
|
|
419
|
+
try {
|
|
420
|
+
diskEntries = fs.readdirSync(this.diskDir).length;
|
|
421
|
+
} catch (err) {
|
|
422
|
+
// Ignore
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
name: this.name,
|
|
427
|
+
memoryEntries: this.memory.size,
|
|
428
|
+
diskEntries,
|
|
429
|
+
maxMemory: this.MAX_MEMORY
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Initialize caches
|
|
435
|
+
// Note: For stdio MCP servers (spawned per-call), use higher disk probability
|
|
436
|
+
// Memory cache is within-session, disk cache persists across sessions
|
|
437
|
+
const searchCache = new HybridCache('search', {
|
|
438
|
+
maxEntries: 200,
|
|
439
|
+
ttl: 6 * 60 * 60 * 1000, // 6 hours for search
|
|
440
|
+
diskWriteProbability: 1.0 // Always write to disk for stdio MCP
|
|
441
|
+
});
|
|
442
|
+
const endpointCache = new HybridCache('endpoints', {
|
|
443
|
+
maxEntries: 100,
|
|
444
|
+
ttl: 24 * 60 * 60 * 1000, // 24 hours for endpoints
|
|
445
|
+
diskWriteProbability: 1.0 // Always write to disk for stdio MCP
|
|
446
|
+
});
|
|
447
|
+
const urlCache = new HybridCache('urls', {
|
|
448
|
+
maxEntries: 200,
|
|
449
|
+
ttl: 24 * 60 * 60 * 1000, // 24 hours for URL lookups
|
|
450
|
+
diskWriteProbability: 1.0 // Always write to disk for stdio MCP
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Periodic cleanup (every hour)
|
|
454
|
+
let cleanupInterval = null;
|
|
455
|
+
function startCacheCleanup() {
|
|
456
|
+
if (cleanupInterval) return;
|
|
457
|
+
cleanupInterval = setInterval(() => {
|
|
458
|
+
searchCache.cleanup();
|
|
459
|
+
endpointCache.cleanup();
|
|
460
|
+
urlCache.cleanup();
|
|
461
|
+
}, 60 * 60 * 1000); // 1 hour
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function stopCacheCleanup() {
|
|
465
|
+
if (cleanupInterval) {
|
|
466
|
+
clearInterval(cleanupInterval);
|
|
467
|
+
cleanupInterval = null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function getAllCacheStats() {
|
|
472
|
+
return {
|
|
473
|
+
search: searchCache.stats(),
|
|
474
|
+
endpoints: endpointCache.stats(),
|
|
475
|
+
urls: urlCache.stats()
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ========== INPUT VALIDATION ==========
|
|
480
|
+
const VALIDATION = {
|
|
481
|
+
QUERY_MAX_LENGTH: 500,
|
|
482
|
+
QUERY_MIN_LENGTH: 1,
|
|
483
|
+
ENDPOINT_PATTERN: /^[a-zA-Z0-9_\-]+$/,
|
|
484
|
+
GUIDE_NAMES: ['pricing', 'qps_limits', 'tweet_filter_rules', 'changelog', 'introduction', 'authentication', 'readme'],
|
|
485
|
+
CATEGORIES: ['user', 'tweet', 'community', 'webhook', 'stream', 'action', 'dm', 'list', 'trend', 'other']
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
function validateQuery(query) {
|
|
489
|
+
if (!query || typeof query !== 'string') {
|
|
490
|
+
return {
|
|
491
|
+
valid: false,
|
|
492
|
+
error: {
|
|
493
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
494
|
+
message: 'Query cannot be empty',
|
|
495
|
+
suggestion: 'Try: "user info", "advanced search", "rate limits", "webhook"',
|
|
496
|
+
retryable: false
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const trimmed = query.trim();
|
|
502
|
+
|
|
503
|
+
if (trimmed.length < VALIDATION.QUERY_MIN_LENGTH) {
|
|
504
|
+
return {
|
|
505
|
+
valid: false,
|
|
506
|
+
error: {
|
|
507
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
508
|
+
message: 'Query too short',
|
|
509
|
+
suggestion: 'Enter at least 1 character. Examples: "tweet", "user", "search"',
|
|
510
|
+
retryable: false
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (trimmed.length > VALIDATION.QUERY_MAX_LENGTH) {
|
|
516
|
+
return {
|
|
517
|
+
valid: false,
|
|
518
|
+
error: {
|
|
519
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
520
|
+
message: `Query too long (${trimmed.length} chars, max ${VALIDATION.QUERY_MAX_LENGTH})`,
|
|
521
|
+
suggestion: 'Use fewer, more specific keywords',
|
|
522
|
+
retryable: false
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return { valid: true, value: trimmed };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function validateEndpointName(name) {
|
|
531
|
+
if (!name || typeof name !== 'string') {
|
|
532
|
+
return {
|
|
533
|
+
valid: false,
|
|
534
|
+
error: {
|
|
535
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
536
|
+
message: 'Endpoint name cannot be empty',
|
|
537
|
+
suggestion: 'Use list_twitterapi_endpoints to see available endpoints',
|
|
538
|
+
retryable: false
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const trimmed = name.trim();
|
|
544
|
+
|
|
545
|
+
if (!VALIDATION.ENDPOINT_PATTERN.test(trimmed)) {
|
|
546
|
+
return {
|
|
547
|
+
valid: false,
|
|
548
|
+
error: {
|
|
549
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
550
|
+
message: 'Invalid endpoint name format',
|
|
551
|
+
suggestion: 'Use format like: get_user_info, tweet_advanced_search, add_webhook_rule',
|
|
552
|
+
retryable: false
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { valid: true, value: trimmed };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function validateGuideName(name, availableGuideNames = VALIDATION.GUIDE_NAMES) {
|
|
561
|
+
if (!name || typeof name !== 'string') {
|
|
562
|
+
return {
|
|
563
|
+
valid: false,
|
|
564
|
+
error: {
|
|
565
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
566
|
+
message: 'Guide name cannot be empty',
|
|
567
|
+
suggestion: `Available guides: ${availableGuideNames.join(', ')}`,
|
|
568
|
+
retryable: false
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const trimmed = name.trim().toLowerCase();
|
|
574
|
+
|
|
575
|
+
if (!availableGuideNames.includes(trimmed)) {
|
|
576
|
+
return {
|
|
577
|
+
valid: false,
|
|
578
|
+
error: {
|
|
579
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
580
|
+
message: `Unknown guide: "${trimmed}"`,
|
|
581
|
+
suggestion: `Available guides: ${availableGuideNames.join(', ')}`,
|
|
582
|
+
retryable: false
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return { valid: true, value: trimmed };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function validateCategory(category) {
|
|
591
|
+
if (!category) {
|
|
592
|
+
return { valid: true, value: null }; // Optional parameter
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const trimmed = category.trim().toLowerCase();
|
|
596
|
+
|
|
597
|
+
if (!VALIDATION.CATEGORIES.includes(trimmed)) {
|
|
598
|
+
return {
|
|
599
|
+
valid: false,
|
|
600
|
+
error: {
|
|
601
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
602
|
+
message: `Unknown category: "${trimmed}"`,
|
|
603
|
+
suggestion: `Available categories: ${VALIDATION.CATEGORIES.join(', ')}`,
|
|
604
|
+
retryable: false
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return { valid: true, value: trimmed };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const ALLOWED_URL_HOSTS = new Set(['twitterapi.io', 'docs.twitterapi.io']);
|
|
613
|
+
|
|
614
|
+
function canonicalizeUrl(rawUrl) {
|
|
615
|
+
const trimmed = rawUrl.trim();
|
|
616
|
+
if (!trimmed) throw new Error('URL cannot be empty');
|
|
617
|
+
|
|
618
|
+
let candidate = trimmed;
|
|
619
|
+
if (candidate.startsWith('/')) {
|
|
620
|
+
candidate = `https://twitterapi.io${candidate}`;
|
|
621
|
+
} else if (/^(twitterapi\.io|docs\.twitterapi\.io)(?:$|[/?#])/i.test(candidate)) {
|
|
622
|
+
candidate = `https://${candidate}`;
|
|
623
|
+
} else if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(candidate)) {
|
|
624
|
+
// Allow convenient inputs like "pricing" or "qps-limits"
|
|
625
|
+
candidate = `https://twitterapi.io/${candidate}`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const u = new URL(candidate);
|
|
629
|
+
if (u.protocol === 'http:') {
|
|
630
|
+
u.protocol = 'https:';
|
|
631
|
+
}
|
|
632
|
+
if (u.hostname === 'www.twitterapi.io') {
|
|
633
|
+
u.hostname = 'twitterapi.io';
|
|
634
|
+
}
|
|
635
|
+
if (u.protocol !== 'https:') {
|
|
636
|
+
throw new Error('Only https URLs are supported');
|
|
637
|
+
}
|
|
638
|
+
if (!ALLOWED_URL_HOSTS.has(u.hostname)) {
|
|
639
|
+
throw new Error(`Unsupported host: ${u.hostname}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Ignore fragments and common tracking/query params for matching
|
|
643
|
+
u.hash = '';
|
|
644
|
+
u.search = '';
|
|
645
|
+
if (u.pathname !== '/' && u.pathname.endsWith('/')) {
|
|
646
|
+
u.pathname = u.pathname.slice(0, -1);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return u.toString();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function normalizeKeyForName(input) {
|
|
653
|
+
return input
|
|
654
|
+
.toLowerCase()
|
|
655
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
656
|
+
.replace(/^_+|_+$/g, '')
|
|
657
|
+
.replace(/_+/g, '_');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function validateTwitterApiUrl(url) {
|
|
661
|
+
if (!url || typeof url !== 'string') {
|
|
662
|
+
return {
|
|
663
|
+
valid: false,
|
|
664
|
+
error: {
|
|
665
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
666
|
+
message: 'URL cannot be empty',
|
|
667
|
+
suggestion: 'Provide a full URL like https://twitterapi.io/pricing or https://docs.twitterapi.io/introduction',
|
|
668
|
+
retryable: false
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
return { valid: true, value: canonicalizeUrl(url) };
|
|
675
|
+
} catch (err) {
|
|
676
|
+
return {
|
|
677
|
+
valid: false,
|
|
678
|
+
error: {
|
|
679
|
+
type: ErrorType.INPUT_VALIDATION,
|
|
680
|
+
message: `Invalid URL: ${err.message}`,
|
|
681
|
+
suggestion: 'Only https://twitterapi.io/* and https://docs.twitterapi.io/* URLs are supported',
|
|
682
|
+
retryable: false
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ========== DATA LOADING ==========
|
|
689
|
+
let cachedDocs = null;
|
|
690
|
+
let lastModified = 0;
|
|
691
|
+
|
|
692
|
+
// Data freshness configuration
|
|
693
|
+
const DATA_FRESHNESS = {
|
|
694
|
+
WARNING_THRESHOLD: 24 * 60 * 60 * 1000, // 24 hours
|
|
695
|
+
STALE_THRESHOLD: 72 * 60 * 60 * 1000, // 72 hours
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
function loadDocs() {
|
|
699
|
+
try {
|
|
700
|
+
const stat = fs.statSync(DOCS_PATH);
|
|
701
|
+
const mtime = stat.mtimeMs;
|
|
702
|
+
|
|
703
|
+
if (!cachedDocs || mtime > lastModified) {
|
|
704
|
+
logger.info('docs_loader', 'Loading documentation from disk');
|
|
705
|
+
const content = fs.readFileSync(DOCS_PATH, "utf-8");
|
|
706
|
+
cachedDocs = JSON.parse(content);
|
|
707
|
+
lastModified = mtime;
|
|
708
|
+
logger.recordCacheMiss();
|
|
709
|
+
|
|
710
|
+
const endpointCount = Object.keys(cachedDocs.endpoints || {}).length;
|
|
711
|
+
const pageCount = Object.keys(cachedDocs.pages || {}).length;
|
|
712
|
+
logger.info('docs_loader', 'Documentation loaded', { endpoints: endpointCount, pages: pageCount });
|
|
713
|
+
} else {
|
|
714
|
+
logger.recordCacheHit();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return cachedDocs;
|
|
718
|
+
} catch (err) {
|
|
719
|
+
logger.error('docs_loader', 'Failed to load documentation', err);
|
|
720
|
+
return { endpoints: {}, pages: {}, blogs: {}, authentication: {}, meta: {} };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Get data freshness information
|
|
726
|
+
* Returns object with age, status (fresh/warning/stale), and human-readable age
|
|
727
|
+
*/
|
|
728
|
+
function getDataFreshness() {
|
|
729
|
+
try {
|
|
730
|
+
const stat = fs.statSync(DOCS_PATH);
|
|
731
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
732
|
+
const ageHours = ageMs / (60 * 60 * 1000);
|
|
733
|
+
const ageDays = ageHours / 24;
|
|
734
|
+
|
|
735
|
+
let status = 'fresh';
|
|
736
|
+
if (ageMs > DATA_FRESHNESS.STALE_THRESHOLD) {
|
|
737
|
+
status = 'stale';
|
|
738
|
+
logger.warn('data_freshness', 'Documentation is STALE', {
|
|
739
|
+
ageHours: ageHours.toFixed(1),
|
|
740
|
+
threshold: DATA_FRESHNESS.STALE_THRESHOLD / (60 * 60 * 1000)
|
|
741
|
+
});
|
|
742
|
+
} else if (ageMs > DATA_FRESHNESS.WARNING_THRESHOLD) {
|
|
743
|
+
status = 'warning';
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
lastModified: new Date(stat.mtimeMs).toISOString(),
|
|
748
|
+
ageMs,
|
|
749
|
+
ageHuman: ageDays >= 1
|
|
750
|
+
? `${ageDays.toFixed(1)} days`
|
|
751
|
+
: `${ageHours.toFixed(1)} hours`,
|
|
752
|
+
status,
|
|
753
|
+
thresholds: {
|
|
754
|
+
warning: `${DATA_FRESHNESS.WARNING_THRESHOLD / (60 * 60 * 1000)}h`,
|
|
755
|
+
stale: `${DATA_FRESHNESS.STALE_THRESHOLD / (60 * 60 * 1000)}h`
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
} catch (err) {
|
|
759
|
+
logger.error('data_freshness', 'Failed to check data freshness', err);
|
|
760
|
+
return {
|
|
761
|
+
lastModified: null,
|
|
762
|
+
ageMs: null,
|
|
763
|
+
ageHuman: 'unknown',
|
|
764
|
+
status: 'error',
|
|
765
|
+
error: err.message
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ========== SEARCH FUNCTIONS ==========
|
|
771
|
+
/**
|
|
772
|
+
* Advanced tokenizer with camelCase and compound word support
|
|
773
|
+
* Examples:
|
|
774
|
+
* "getUserInfo" → ["get", "user", "info"]
|
|
775
|
+
* "get_user_info" → ["get", "user", "info"]
|
|
776
|
+
* "OAuth2Token" → ["oauth", "2", "token"]
|
|
777
|
+
*/
|
|
778
|
+
function tokenize(text) {
|
|
779
|
+
// Step 1: Split camelCase and PascalCase
|
|
780
|
+
// "getUserInfo" → "get User Info"
|
|
781
|
+
// "OAuth2Token" → "O Auth 2 Token"
|
|
782
|
+
let processed = text.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
783
|
+
|
|
784
|
+
// Step 2: Split numbers from letters
|
|
785
|
+
// "OAuth2Token" → "OAuth 2 Token"
|
|
786
|
+
processed = processed.replace(/([a-zA-Z])(\d)/g, '$1 $2');
|
|
787
|
+
processed = processed.replace(/(\d)([a-zA-Z])/g, '$1 $2');
|
|
788
|
+
|
|
789
|
+
// Step 3: Replace separators with spaces
|
|
790
|
+
processed = processed
|
|
791
|
+
.toLowerCase()
|
|
792
|
+
.replace(/[_\-\/\.]/g, ' ')
|
|
793
|
+
.replace(/[^a-z0-9\s]/g, '');
|
|
794
|
+
|
|
795
|
+
// Step 4: Split and filter
|
|
796
|
+
const tokens = processed
|
|
797
|
+
.split(/\s+/)
|
|
798
|
+
.filter(t => t.length > 1);
|
|
799
|
+
|
|
800
|
+
// Step 5: Deduplicate while preserving order
|
|
801
|
+
return [...new Set(tokens)];
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Generates n-grams from tokens for fuzzy matching
|
|
806
|
+
*/
|
|
807
|
+
function generateNGrams(tokens, n = 2) {
|
|
808
|
+
const ngrams = [];
|
|
809
|
+
for (const token of tokens) {
|
|
810
|
+
if (token.length >= n) {
|
|
811
|
+
for (let i = 0; i <= token.length - n; i++) {
|
|
812
|
+
ngrams.push(token.slice(i, i + n));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return ngrams;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Advanced scoring algorithm with weighted matching
|
|
821
|
+
* Score breakdown:
|
|
822
|
+
* - Exact token match: 1.0
|
|
823
|
+
* - Prefix match: 0.8
|
|
824
|
+
* - Substring match: 0.6
|
|
825
|
+
* - N-gram match: 0.3
|
|
826
|
+
* - Multiple token bonus: +0.5 per additional match
|
|
827
|
+
*/
|
|
828
|
+
function calculateScore(searchText, queryTokens) {
|
|
829
|
+
const textLower = searchText.toLowerCase();
|
|
830
|
+
const textTokens = tokenize(searchText);
|
|
831
|
+
const textNGrams = new Set(generateNGrams(textTokens));
|
|
832
|
+
let score = 0;
|
|
833
|
+
let matchCount = 0;
|
|
834
|
+
|
|
835
|
+
for (const token of queryTokens) {
|
|
836
|
+
let tokenScore = 0;
|
|
837
|
+
|
|
838
|
+
// Exact token match (highest weight)
|
|
839
|
+
if (textTokens.includes(token)) {
|
|
840
|
+
tokenScore = 10;
|
|
841
|
+
matchCount++;
|
|
842
|
+
}
|
|
843
|
+
// Prefix match
|
|
844
|
+
else if (textTokens.some(t => t.startsWith(token))) {
|
|
845
|
+
tokenScore = 8;
|
|
846
|
+
matchCount++;
|
|
847
|
+
}
|
|
848
|
+
// Suffix/substring match
|
|
849
|
+
else if (textTokens.some(t => t.includes(token) || token.includes(t))) {
|
|
850
|
+
tokenScore = 6;
|
|
851
|
+
matchCount++;
|
|
852
|
+
}
|
|
853
|
+
// Direct text inclusion (handles compound words)
|
|
854
|
+
else if (textLower.includes(token)) {
|
|
855
|
+
tokenScore = 5;
|
|
856
|
+
matchCount++;
|
|
857
|
+
}
|
|
858
|
+
// N-gram fuzzy match
|
|
859
|
+
else {
|
|
860
|
+
const queryNGrams = generateNGrams([token]);
|
|
861
|
+
const ngramMatches = queryNGrams.filter(ng => textNGrams.has(ng)).length;
|
|
862
|
+
if (ngramMatches > 0) {
|
|
863
|
+
tokenScore = Math.min(3, ngramMatches * 0.5);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
score += tokenScore;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Multi-token bonus: reward results that match multiple query terms
|
|
871
|
+
if (matchCount > 1) {
|
|
872
|
+
score += matchCount * 5;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Position bonus: boost if match appears in first word (likely endpoint name)
|
|
876
|
+
if (textTokens.length > 0 && queryTokens.some(t => textTokens[0].includes(t))) {
|
|
877
|
+
score += 3;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return score;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function searchInDocs(query, maxResults = 20) {
|
|
884
|
+
const data = loadDocs();
|
|
885
|
+
const queryTokens = tokenize(query);
|
|
886
|
+
const results = [];
|
|
887
|
+
|
|
888
|
+
// Search endpoints
|
|
889
|
+
for (const [name, item] of Object.entries(data.endpoints || {})) {
|
|
890
|
+
const searchText = [
|
|
891
|
+
name,
|
|
892
|
+
item.title || "",
|
|
893
|
+
item.description || "",
|
|
894
|
+
item.method || "",
|
|
895
|
+
item.path || "",
|
|
896
|
+
item.curl_example || "",
|
|
897
|
+
item.raw_text || "",
|
|
898
|
+
(item.parameters || []).map(p => p.name + ' ' + p.description).join(' '),
|
|
899
|
+
].join(" ");
|
|
900
|
+
|
|
901
|
+
const score = calculateScore(searchText, queryTokens);
|
|
902
|
+
if (score > 0) {
|
|
903
|
+
results.push({
|
|
904
|
+
type: "endpoint",
|
|
905
|
+
name,
|
|
906
|
+
title: item.title,
|
|
907
|
+
description: item.description,
|
|
908
|
+
method: item.method,
|
|
909
|
+
path: item.path,
|
|
910
|
+
url: item.url,
|
|
911
|
+
score,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Search pages
|
|
917
|
+
for (const [name, item] of Object.entries(data.pages || {})) {
|
|
918
|
+
const searchText = [
|
|
919
|
+
name,
|
|
920
|
+
item.title || "",
|
|
921
|
+
item.description || "",
|
|
922
|
+
item.raw_text || "",
|
|
923
|
+
(item.paragraphs || []).join(" "),
|
|
924
|
+
(item.list_items || []).join(" "),
|
|
925
|
+
(item.headers || []).map(h => h.text).join(" "),
|
|
926
|
+
].join(" ");
|
|
927
|
+
|
|
928
|
+
const score = calculateScore(searchText, queryTokens);
|
|
929
|
+
if (score > 0) {
|
|
930
|
+
results.push({
|
|
931
|
+
type: "page",
|
|
932
|
+
name,
|
|
933
|
+
title: item.title,
|
|
934
|
+
description: item.description,
|
|
935
|
+
url: item.url,
|
|
936
|
+
category: item.category,
|
|
937
|
+
score,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Search blogs
|
|
943
|
+
for (const [name, item] of Object.entries(data.blogs || {})) {
|
|
944
|
+
const searchText = [
|
|
945
|
+
name,
|
|
946
|
+
item.title || "",
|
|
947
|
+
item.description || "",
|
|
948
|
+
item.raw_text || "",
|
|
949
|
+
(item.paragraphs || []).join(" "),
|
|
950
|
+
].join(" ");
|
|
951
|
+
|
|
952
|
+
const score = calculateScore(searchText, queryTokens);
|
|
953
|
+
if (score > 0) {
|
|
954
|
+
results.push({
|
|
955
|
+
type: "blog",
|
|
956
|
+
name,
|
|
957
|
+
title: item.title,
|
|
958
|
+
description: item.description,
|
|
959
|
+
url: item.url,
|
|
960
|
+
score,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return results.sort((a, b) => b.score - a.score).slice(0, maxResults);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function decodeHtmlEntities(text) {
|
|
969
|
+
return text
|
|
970
|
+
.replace(/</g, '<')
|
|
971
|
+
.replace(/>/g, '>')
|
|
972
|
+
.replace(/'/g, "'")
|
|
973
|
+
.replace(/"/g, '"')
|
|
974
|
+
.replace(/&/g, '&')
|
|
975
|
+
.replace(/</g, '<')
|
|
976
|
+
.replace(/>/g, '>')
|
|
977
|
+
.replace(/'/g, "'")
|
|
978
|
+
.replace(/ /g, ' ');
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function extractHtmlContent(html) {
|
|
982
|
+
const titleMatch = html.match(/<title>([^<]+)<\/title>/i);
|
|
983
|
+
const title = titleMatch ? decodeHtmlEntities(titleMatch[1].trim()) : '';
|
|
984
|
+
|
|
985
|
+
const descMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]+)"/i);
|
|
986
|
+
const description = descMatch ? decodeHtmlEntities(descMatch[1].trim()) : '';
|
|
987
|
+
|
|
988
|
+
const headers = [];
|
|
989
|
+
for (const m of html.matchAll(/<h([1-3])[^>]*>([\s\S]*?)<\/h\1>/gi)) {
|
|
990
|
+
const level = Number(m[1]);
|
|
991
|
+
const text = decodeHtmlEntities(m[2].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
|
|
992
|
+
if (text) headers.push({ level, text });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const paragraphs = [];
|
|
996
|
+
for (const m of html.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
|
|
997
|
+
const text = decodeHtmlEntities(m[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
|
|
998
|
+
if (text.length > 10) paragraphs.push(text);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const list_items = [];
|
|
1002
|
+
for (const m of html.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)) {
|
|
1003
|
+
const text = decodeHtmlEntities(m[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
|
|
1004
|
+
if (text.length > 3) list_items.push(text);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const code_snippets = [];
|
|
1008
|
+
for (const m of html.matchAll(/<pre[^>]*>([\s\S]*?)<\/pre>/gi)) {
|
|
1009
|
+
const text = decodeHtmlEntities(m[1].replace(/<[^>]+>/g, '').trim());
|
|
1010
|
+
if (text.length > 10) code_snippets.push(text);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const raw_text = decodeHtmlEntities(
|
|
1014
|
+
html
|
|
1015
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
1016
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
1017
|
+
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
|
1018
|
+
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
|
1019
|
+
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
|
1020
|
+
.replace(/<[^>]+>/g, ' ')
|
|
1021
|
+
.replace(/\s+/g, ' ')
|
|
1022
|
+
.trim()
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
return { title, description, headers, paragraphs, list_items, code_snippets, raw_text };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function formatGuideMarkdown(name, page) {
|
|
1029
|
+
let output = `# ${page.title || name}\n\n`;
|
|
1030
|
+
output += `**URL:** ${page.url || "N/A"}\n\n`;
|
|
1031
|
+
|
|
1032
|
+
if (page.description) {
|
|
1033
|
+
output += `## Overview\n${page.description}\n\n`;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (page.headers?.length > 0) {
|
|
1037
|
+
output += `## Table of Contents\n`;
|
|
1038
|
+
output += page.headers.map(h => `${" ".repeat(h.level - 1)}- ${h.text}`).join("\n");
|
|
1039
|
+
output += "\n\n";
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (page.paragraphs?.length > 0) {
|
|
1043
|
+
output += `## Content\n`;
|
|
1044
|
+
output += page.paragraphs.join("\n\n");
|
|
1045
|
+
output += "\n\n";
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if (page.list_items?.length > 0) {
|
|
1049
|
+
output += `## Key Points\n`;
|
|
1050
|
+
output += page.list_items.map(li => `- ${li}`).join("\n");
|
|
1051
|
+
output += "\n\n";
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (page.code_snippets?.length > 0) {
|
|
1055
|
+
output += `## Code Examples\n\`\`\`\n`;
|
|
1056
|
+
output += page.code_snippets.join("\n");
|
|
1057
|
+
output += "\n```\n\n";
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
output += `## Full Content\n${page.raw_text || "No additional content."}`;
|
|
1061
|
+
return output;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function formatEndpointMarkdown(endpointName, endpoint) {
|
|
1065
|
+
const curlExample =
|
|
1066
|
+
endpoint.curl_example ||
|
|
1067
|
+
`curl --request ${endpoint.method || 'GET'} \\\n --url https://api.twitterapi.io${endpoint.path || ''} \\\n --header 'x-api-key: YOUR_API_KEY'`;
|
|
1068
|
+
|
|
1069
|
+
return `# ${endpoint.title || endpointName}
|
|
1070
|
+
|
|
1071
|
+
## Endpoint Details
|
|
1072
|
+
- **Method:** ${endpoint.method || "GET"}
|
|
1073
|
+
- **Path:** ${endpoint.path || "Unknown"}
|
|
1074
|
+
- **Full URL:** https://api.twitterapi.io${endpoint.path || ""}
|
|
1075
|
+
- **Documentation:** ${endpoint.url}
|
|
1076
|
+
|
|
1077
|
+
## Description
|
|
1078
|
+
${endpoint.description || "No description available."}
|
|
1079
|
+
|
|
1080
|
+
${endpoint.parameters?.length > 0 ? `## Parameters
|
|
1081
|
+
${endpoint.parameters.map(p => `- **${p.name}**${p.required ? ' (required)' : ''}: ${p.description}`).join('\n')}` : ''}
|
|
1082
|
+
|
|
1083
|
+
## cURL Example
|
|
1084
|
+
\`\`\`bash
|
|
1085
|
+
${curlExample}
|
|
1086
|
+
\`\`\`
|
|
1087
|
+
|
|
1088
|
+
${endpoint.code_snippets?.length > 0 ? `## Code Examples
|
|
1089
|
+
\`\`\`
|
|
1090
|
+
${endpoint.code_snippets.join("\n")}
|
|
1091
|
+
\`\`\`` : ""}
|
|
1092
|
+
|
|
1093
|
+
## Full Documentation
|
|
1094
|
+
${endpoint.raw_text || "No additional content available."}`;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function safeCanonicalizeUrl(url) {
|
|
1098
|
+
try {
|
|
1099
|
+
return canonicalizeUrl(url);
|
|
1100
|
+
} catch (_err) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function findSnapshotItemByUrl(data, canonicalUrl) {
|
|
1106
|
+
for (const [name, ep] of Object.entries(data.endpoints || {})) {
|
|
1107
|
+
const epUrl = safeCanonicalizeUrl(ep?.url);
|
|
1108
|
+
if (epUrl && epUrl === canonicalUrl) {
|
|
1109
|
+
return { kind: 'endpoint', name, item: ep };
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
for (const [name, page] of Object.entries(data.pages || {})) {
|
|
1114
|
+
const pageUrl = safeCanonicalizeUrl(page?.url);
|
|
1115
|
+
if (pageUrl && pageUrl === canonicalUrl) {
|
|
1116
|
+
return { kind: 'page', name, item: page };
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
for (const [name, blog] of Object.entries(data.blogs || {})) {
|
|
1121
|
+
const blogUrl = safeCanonicalizeUrl(blog?.url);
|
|
1122
|
+
if (blogUrl && blogUrl === canonicalUrl) {
|
|
1123
|
+
return { kind: 'blog', name, item: blog };
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ========== MCP SERVER ==========
|
|
1131
|
+
const server = new Server(
|
|
1132
|
+
{
|
|
1133
|
+
name: "twitterapi-docs",
|
|
1134
|
+
version: "1.0.9",
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
capabilities: {
|
|
1138
|
+
tools: {},
|
|
1139
|
+
resources: {},
|
|
1140
|
+
completions: {},
|
|
1141
|
+
},
|
|
1142
|
+
}
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
// ========== TOOL DEFINITIONS (LLM-OPTIMIZED) ==========
|
|
1146
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1147
|
+
const docs = loadDocs();
|
|
1148
|
+
const availablePages = Object.keys(docs.pages || {}).sort();
|
|
1149
|
+
|
|
1150
|
+
return {
|
|
1151
|
+
tools: [
|
|
1152
|
+
{
|
|
1153
|
+
name: "search_twitterapi_docs",
|
|
1154
|
+
description: `Search TwitterAPI.io documentation: API endpoints, guides (pricing, rate limits, filter rules), and blog posts.
|
|
1155
|
+
|
|
1156
|
+
USE THIS WHEN: You need to find information across the entire documentation.
|
|
1157
|
+
RETURNS: Ranked results with endpoint paths, descriptions, and relevance scores.
|
|
1158
|
+
|
|
1159
|
+
Examples:
|
|
1160
|
+
- "advanced search" → finds tweet search endpoints
|
|
1161
|
+
- "rate limit" → finds QPS limits and pricing info
|
|
1162
|
+
- "webhook" → finds webhook setup endpoints
|
|
1163
|
+
- "getUserInfo" → finds user info endpoints (supports camelCase)`,
|
|
1164
|
+
inputSchema: {
|
|
1165
|
+
type: "object",
|
|
1166
|
+
properties: {
|
|
1167
|
+
query: {
|
|
1168
|
+
type: "string",
|
|
1169
|
+
description: "Search query (1-500 chars). Use English keywords like: 'search', 'user', 'tweet', 'webhook', 'pricing', 'rate limit'. Supports camelCase and underscore formats.",
|
|
1170
|
+
minLength: 1,
|
|
1171
|
+
maxLength: 500
|
|
1172
|
+
},
|
|
1173
|
+
max_results: {
|
|
1174
|
+
type: "integer",
|
|
1175
|
+
description: "Number of results to return. Use higher values (15-20) for comprehensive research, lower values (3-5) for quick lookups.",
|
|
1176
|
+
minimum: 1,
|
|
1177
|
+
maximum: 20,
|
|
1178
|
+
default: 10
|
|
1179
|
+
}
|
|
1180
|
+
},
|
|
1181
|
+
required: ["query"],
|
|
1182
|
+
},
|
|
1183
|
+
outputSchema: {
|
|
1184
|
+
type: "object",
|
|
1185
|
+
properties: {
|
|
1186
|
+
query: { type: "string", description: "Normalized (trimmed) search query." },
|
|
1187
|
+
max_results: { type: "integer", description: "Applied max results (1-20)." },
|
|
1188
|
+
cached: { type: "boolean", description: "Whether this response was served from cache." },
|
|
1189
|
+
counts: {
|
|
1190
|
+
type: "object",
|
|
1191
|
+
properties: {
|
|
1192
|
+
total: { type: "integer" },
|
|
1193
|
+
endpoints: { type: "integer" },
|
|
1194
|
+
pages: { type: "integer" },
|
|
1195
|
+
blogs: { type: "integer" }
|
|
1196
|
+
}
|
|
1197
|
+
},
|
|
1198
|
+
results: {
|
|
1199
|
+
type: "array",
|
|
1200
|
+
items: {
|
|
1201
|
+
type: "object",
|
|
1202
|
+
properties: {
|
|
1203
|
+
type: { type: "string", enum: ["endpoint", "page", "blog"] },
|
|
1204
|
+
name: { type: "string" },
|
|
1205
|
+
title: { type: "string" },
|
|
1206
|
+
description: { type: "string" },
|
|
1207
|
+
url: { type: "string" },
|
|
1208
|
+
category: { type: "string" },
|
|
1209
|
+
method: { type: "string" },
|
|
1210
|
+
path: { type: "string" },
|
|
1211
|
+
score: { type: "number" }
|
|
1212
|
+
},
|
|
1213
|
+
required: ["type", "score"]
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
markdown: { type: "string", description: "Human-readable markdown rendering of the results." }
|
|
1217
|
+
},
|
|
1218
|
+
required: ["query", "max_results", "results", "markdown"]
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
{
|
|
1222
|
+
name: "get_twitterapi_endpoint",
|
|
1223
|
+
description: `Get complete documentation for a specific TwitterAPI.io endpoint.
|
|
1224
|
+
|
|
1225
|
+
USE THIS WHEN: You know the exact endpoint name (e.g., from search results).
|
|
1226
|
+
RETURNS: Full details including path, parameters, cURL example, and code snippets.
|
|
1227
|
+
|
|
1228
|
+
Common endpoints:
|
|
1229
|
+
- get_user_info, get_user_followers, get_user_following
|
|
1230
|
+
- tweet_advanced_search, get_tweet_by_id
|
|
1231
|
+
- add_webhook_rule, get_webhook_rules`,
|
|
1232
|
+
inputSchema: {
|
|
1233
|
+
type: "object",
|
|
1234
|
+
properties: {
|
|
1235
|
+
endpoint_name: {
|
|
1236
|
+
type: "string",
|
|
1237
|
+
description: "Exact endpoint name (use underscores). Examples: 'get_user_info', 'tweet_advanced_search', 'add_webhook_rule'",
|
|
1238
|
+
},
|
|
1239
|
+
},
|
|
1240
|
+
required: ["endpoint_name"],
|
|
1241
|
+
},
|
|
1242
|
+
outputSchema: {
|
|
1243
|
+
type: "object",
|
|
1244
|
+
properties: {
|
|
1245
|
+
endpoint_name: { type: "string" },
|
|
1246
|
+
title: { type: "string" },
|
|
1247
|
+
method: { type: "string" },
|
|
1248
|
+
path: { type: "string" },
|
|
1249
|
+
full_url: { type: "string" },
|
|
1250
|
+
doc_url: { type: "string" },
|
|
1251
|
+
description: { type: "string" },
|
|
1252
|
+
parameters: {
|
|
1253
|
+
type: "array",
|
|
1254
|
+
items: {
|
|
1255
|
+
type: "object",
|
|
1256
|
+
properties: {
|
|
1257
|
+
name: { type: "string" },
|
|
1258
|
+
required: { type: "boolean" },
|
|
1259
|
+
description: { type: "string" }
|
|
1260
|
+
},
|
|
1261
|
+
required: ["name"]
|
|
1262
|
+
}
|
|
1263
|
+
},
|
|
1264
|
+
curl_example: { type: "string" },
|
|
1265
|
+
code_snippets: { type: "array", items: { type: "string" } },
|
|
1266
|
+
raw_text: { type: "string" },
|
|
1267
|
+
cached: { type: "boolean" },
|
|
1268
|
+
markdown: { type: "string" }
|
|
1269
|
+
},
|
|
1270
|
+
required: ["endpoint_name", "markdown"]
|
|
1271
|
+
}
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
name: "list_twitterapi_endpoints",
|
|
1275
|
+
description: `List all TwitterAPI.io API endpoints organized by category.
|
|
1276
|
+
|
|
1277
|
+
USE THIS WHEN: You need to browse available endpoints or find endpoints by category.
|
|
1278
|
+
CATEGORIES: user, tweet, community, webhook, stream, action, dm, list, trend
|
|
1279
|
+
|
|
1280
|
+
RETURNS: Endpoint names with HTTP method and path for each category.`,
|
|
1281
|
+
inputSchema: {
|
|
1282
|
+
type: "object",
|
|
1283
|
+
properties: {
|
|
1284
|
+
category: {
|
|
1285
|
+
type: "string",
|
|
1286
|
+
description: "Optional filter: user, tweet, community, webhook, stream, action, dm, list, trend, other",
|
|
1287
|
+
enum: ["user", "tweet", "community", "webhook", "stream", "action", "dm", "list", "trend", "other"]
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
},
|
|
1291
|
+
outputSchema: {
|
|
1292
|
+
type: "object",
|
|
1293
|
+
properties: {
|
|
1294
|
+
category: { type: ["string", "null"] },
|
|
1295
|
+
total: { type: "integer" },
|
|
1296
|
+
endpoints: {
|
|
1297
|
+
type: "array",
|
|
1298
|
+
items: {
|
|
1299
|
+
type: "object",
|
|
1300
|
+
properties: {
|
|
1301
|
+
name: { type: "string" },
|
|
1302
|
+
method: { type: "string" },
|
|
1303
|
+
path: { type: "string" },
|
|
1304
|
+
description: { type: "string" },
|
|
1305
|
+
category: { type: "string" }
|
|
1306
|
+
},
|
|
1307
|
+
required: ["name", "category"]
|
|
1308
|
+
}
|
|
1309
|
+
},
|
|
1310
|
+
markdown: { type: "string" }
|
|
1311
|
+
},
|
|
1312
|
+
required: ["total", "endpoints", "markdown"]
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
name: "get_twitterapi_guide",
|
|
1317
|
+
description: `Get a TwitterAPI.io page from the offline snapshot by page key.
|
|
1318
|
+
|
|
1319
|
+
USE THIS WHEN: You need the full content of a specific page (guides, docs, policies, contact, etc.).
|
|
1320
|
+
TIP: Use search_twitterapi_docs if you don't know the page key.
|
|
1321
|
+
|
|
1322
|
+
RETURNS: Full guide content with headers, paragraphs, and code examples.`,
|
|
1323
|
+
inputSchema: {
|
|
1324
|
+
type: "object",
|
|
1325
|
+
properties: {
|
|
1326
|
+
guide_name: {
|
|
1327
|
+
type: "string",
|
|
1328
|
+
description: "Page key (from data/pages). Examples: pricing, qps_limits, privacy, contact, introduction, authentication.",
|
|
1329
|
+
enum: availablePages
|
|
1330
|
+
},
|
|
1331
|
+
},
|
|
1332
|
+
required: ["guide_name"],
|
|
1333
|
+
},
|
|
1334
|
+
outputSchema: {
|
|
1335
|
+
type: "object",
|
|
1336
|
+
properties: {
|
|
1337
|
+
guide_name: { type: "string" },
|
|
1338
|
+
title: { type: "string" },
|
|
1339
|
+
url: { type: "string" },
|
|
1340
|
+
description: { type: "string" },
|
|
1341
|
+
headers: {
|
|
1342
|
+
type: "array",
|
|
1343
|
+
items: {
|
|
1344
|
+
type: "object",
|
|
1345
|
+
properties: {
|
|
1346
|
+
level: { type: "integer" },
|
|
1347
|
+
text: { type: "string" }
|
|
1348
|
+
},
|
|
1349
|
+
required: ["level", "text"]
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
paragraphs: { type: "array", items: { type: "string" } },
|
|
1353
|
+
list_items: { type: "array", items: { type: "string" } },
|
|
1354
|
+
code_snippets: { type: "array", items: { type: "string" } },
|
|
1355
|
+
raw_text: { type: "string" },
|
|
1356
|
+
markdown: { type: "string" }
|
|
1357
|
+
},
|
|
1358
|
+
required: ["guide_name", "markdown"]
|
|
1359
|
+
}
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
name: "get_twitterapi_url",
|
|
1363
|
+
description: `Fetch a TwitterAPI.io or docs.twitterapi.io URL.
|
|
1364
|
+
|
|
1365
|
+
USE THIS WHEN: You have a specific link and want its full content.
|
|
1366
|
+
RETURNS: Parsed content from the offline snapshot. If not found, you can set fetch_live=true (restricted to twitterapi.io/docs.twitterapi.io).`,
|
|
1367
|
+
inputSchema: {
|
|
1368
|
+
type: "object",
|
|
1369
|
+
properties: {
|
|
1370
|
+
url: {
|
|
1371
|
+
type: "string",
|
|
1372
|
+
description: "URL to fetch. Examples: https://twitterapi.io/privacy, /pricing, docs.twitterapi.io/introduction"
|
|
1373
|
+
},
|
|
1374
|
+
fetch_live: {
|
|
1375
|
+
type: "boolean",
|
|
1376
|
+
description: "If true and the URL is missing from the offline snapshot, fetch it live over HTTPS (allowed hosts only).",
|
|
1377
|
+
default: false
|
|
1378
|
+
}
|
|
1379
|
+
},
|
|
1380
|
+
required: ["url"]
|
|
1381
|
+
},
|
|
1382
|
+
outputSchema: {
|
|
1383
|
+
type: "object",
|
|
1384
|
+
properties: {
|
|
1385
|
+
url: { type: "string" },
|
|
1386
|
+
source: { type: "string", enum: ["snapshot", "live"] },
|
|
1387
|
+
kind: { type: "string", enum: ["endpoint", "page", "blog"] },
|
|
1388
|
+
name: { type: "string" },
|
|
1389
|
+
title: { type: "string" },
|
|
1390
|
+
description: { type: "string" },
|
|
1391
|
+
markdown: { type: "string" }
|
|
1392
|
+
},
|
|
1393
|
+
required: ["url", "source", "kind", "name", "markdown"]
|
|
1394
|
+
}
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
name: "get_twitterapi_pricing",
|
|
1398
|
+
description: `Get TwitterAPI.io pricing information: credit system, endpoint costs, QPS limits.
|
|
1399
|
+
|
|
1400
|
+
USE THIS WHEN: You need to know API costs, credit calculations, or rate limits.
|
|
1401
|
+
RETURNS: Pricing tiers, credit costs per endpoint, QPS limits by balance level.`,
|
|
1402
|
+
inputSchema: {
|
|
1403
|
+
type: "object",
|
|
1404
|
+
properties: {},
|
|
1405
|
+
},
|
|
1406
|
+
outputSchema: {
|
|
1407
|
+
type: "object",
|
|
1408
|
+
properties: {
|
|
1409
|
+
credits_per_usd: { type: "number" },
|
|
1410
|
+
minimum_charge: { type: "string" },
|
|
1411
|
+
costs: { type: "object", additionalProperties: { type: "string" } },
|
|
1412
|
+
qps_limits: {
|
|
1413
|
+
type: "object",
|
|
1414
|
+
properties: {
|
|
1415
|
+
free: { type: "string" },
|
|
1416
|
+
paid: { type: "object", additionalProperties: { type: "string" } }
|
|
1417
|
+
}
|
|
1418
|
+
},
|
|
1419
|
+
notes: { type: "array", items: { type: "string" } },
|
|
1420
|
+
markdown: { type: "string" }
|
|
1421
|
+
},
|
|
1422
|
+
required: ["markdown"]
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
name: "get_twitterapi_auth",
|
|
1427
|
+
description: `Get TwitterAPI.io authentication guide: API key usage, headers, code examples.
|
|
1428
|
+
|
|
1429
|
+
USE THIS WHEN: You need to set up authentication or see request examples.
|
|
1430
|
+
RETURNS: API key header format, base URL, cURL/Python/JavaScript examples.`,
|
|
1431
|
+
inputSchema: {
|
|
1432
|
+
type: "object",
|
|
1433
|
+
properties: {},
|
|
1434
|
+
},
|
|
1435
|
+
outputSchema: {
|
|
1436
|
+
type: "object",
|
|
1437
|
+
properties: {
|
|
1438
|
+
header: { type: "string" },
|
|
1439
|
+
base_url: { type: "string" },
|
|
1440
|
+
dashboard_url: { type: "string" },
|
|
1441
|
+
examples: {
|
|
1442
|
+
type: "object",
|
|
1443
|
+
properties: {
|
|
1444
|
+
curl: { type: "string" },
|
|
1445
|
+
python: { type: "string" },
|
|
1446
|
+
javascript: { type: "string" }
|
|
1447
|
+
}
|
|
1448
|
+
},
|
|
1449
|
+
markdown: { type: "string" }
|
|
1450
|
+
},
|
|
1451
|
+
required: ["header", "base_url", "markdown"]
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
],
|
|
1455
|
+
};
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// ========== TOOL HANDLERS ==========
|
|
1459
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1460
|
+
const { name } = request.params;
|
|
1461
|
+
const args = request.params.arguments ?? {};
|
|
1462
|
+
const startTime = Date.now();
|
|
1463
|
+
let success = true;
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
const result = await handleToolCall(name, args);
|
|
1467
|
+
const duration = Date.now() - startTime;
|
|
1468
|
+
logger.recordToolCall(name, duration, !result.isError);
|
|
1469
|
+
logger.info('tool_call', `${name} completed`, { duration, isError: result.isError });
|
|
1470
|
+
return result;
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
success = false;
|
|
1473
|
+
const duration = Date.now() - startTime;
|
|
1474
|
+
logger.recordToolCall(name, duration, false);
|
|
1475
|
+
logger.error('tool_call', `${name} failed`, error);
|
|
1476
|
+
|
|
1477
|
+
return formatToolError({
|
|
1478
|
+
type: ErrorType.INTERNAL_ERROR,
|
|
1479
|
+
message: 'An unexpected error occurred',
|
|
1480
|
+
suggestion: 'Try again or use a different query',
|
|
1481
|
+
retryable: true
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
async function handleToolCall(name, args) {
|
|
1487
|
+
const data = loadDocs();
|
|
1488
|
+
|
|
1489
|
+
switch (name) {
|
|
1490
|
+
case "search_twitterapi_docs": {
|
|
1491
|
+
// Validate input
|
|
1492
|
+
const validation = validateQuery(args.query);
|
|
1493
|
+
if (!validation.valid) {
|
|
1494
|
+
return formatToolError(validation.error);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Validate and set max_results (default: 10, range: 1-20)
|
|
1498
|
+
const maxResults = Math.min(20, Math.max(1, args.max_results || 10));
|
|
1499
|
+
|
|
1500
|
+
// Check cache first (include maxResults in cache key)
|
|
1501
|
+
const cacheKey = `search_${validation.value}_${maxResults}`;
|
|
1502
|
+
const cachedOutput = searchCache.get(cacheKey);
|
|
1503
|
+
if (cachedOutput) {
|
|
1504
|
+
logger.info('search', 'Cache hit', { query: validation.value, maxResults });
|
|
1505
|
+
const cachedMarkdown = typeof cachedOutput === 'string' ? cachedOutput : cachedOutput.markdown;
|
|
1506
|
+
const markdown = `${cachedMarkdown}\n\n*[Cached result]*`;
|
|
1507
|
+
const structuredContent = typeof cachedOutput === 'string'
|
|
1508
|
+
? {
|
|
1509
|
+
query: validation.value,
|
|
1510
|
+
max_results: maxResults,
|
|
1511
|
+
cached: true,
|
|
1512
|
+
counts: { total: 0, endpoints: 0, pages: 0, blogs: 0 },
|
|
1513
|
+
results: [],
|
|
1514
|
+
markdown
|
|
1515
|
+
}
|
|
1516
|
+
: { ...cachedOutput, cached: true, markdown };
|
|
1517
|
+
return formatToolSuccess(markdown, structuredContent);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const results = searchInDocs(validation.value, maxResults);
|
|
1521
|
+
|
|
1522
|
+
if (results.length === 0) {
|
|
1523
|
+
const allEndpoints = Object.keys(data.endpoints || {}).slice(0, 15);
|
|
1524
|
+
const markdown = `No results for "${validation.value}".
|
|
1525
|
+
|
|
1526
|
+
**Suggestions:**
|
|
1527
|
+
- Try different terms: "search", "user", "tweet", "webhook", "stream"
|
|
1528
|
+
- Use English keywords
|
|
1529
|
+
- Try broader terms
|
|
1530
|
+
|
|
1531
|
+
**Available endpoints (sample):**
|
|
1532
|
+
${allEndpoints.map(e => `- ${e}`).join('\n')}
|
|
1533
|
+
|
|
1534
|
+
**Pages:**
|
|
1535
|
+
- pricing, qps_limits, tweet_filter_rules, changelog, authentication`;
|
|
1536
|
+
return formatToolSuccess(markdown, {
|
|
1537
|
+
query: validation.value,
|
|
1538
|
+
max_results: maxResults,
|
|
1539
|
+
cached: false,
|
|
1540
|
+
counts: { total: 0, endpoints: 0, pages: 0, blogs: 0 },
|
|
1541
|
+
results: [],
|
|
1542
|
+
markdown
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const grouped = {
|
|
1547
|
+
endpoint: results.filter(r => r.type === "endpoint"),
|
|
1548
|
+
page: results.filter(r => r.type === "page"),
|
|
1549
|
+
blog: results.filter(r => r.type === "blog"),
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
let output = `## "${validation.value}" - ${results.length} results (showing up to ${maxResults}):\n\n`;
|
|
1553
|
+
|
|
1554
|
+
if (grouped.endpoint.length > 0) {
|
|
1555
|
+
output += `### API Endpoints (${grouped.endpoint.length})\n`;
|
|
1556
|
+
output += grouped.endpoint.slice(0, 15).map((r, i) =>
|
|
1557
|
+
`${i + 1}. **${r.name}** - ${r.method || "GET"} ${r.path || ""}\n ${r.description || r.title || ""}`
|
|
1558
|
+
).join("\n\n");
|
|
1559
|
+
output += "\n\n";
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (grouped.page.length > 0) {
|
|
1563
|
+
output += `### Pages (${grouped.page.length})\n`;
|
|
1564
|
+
output += grouped.page.slice(0, 10).map((r, i) =>
|
|
1565
|
+
`${i + 1}. **${r.name}** - ${r.title || ""}\n ${r.url || ""}`
|
|
1566
|
+
).join("\n\n");
|
|
1567
|
+
output += "\n\n";
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (grouped.blog.length > 0) {
|
|
1571
|
+
output += `### Blog Posts (${grouped.blog.length})\n`;
|
|
1572
|
+
output += grouped.blog.slice(0, 5).map((r, i) =>
|
|
1573
|
+
`${i + 1}. **${r.title || r.name}**\n ${r.url || ""}`
|
|
1574
|
+
).join("\n\n");
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Cache the result
|
|
1578
|
+
const structuredContent = {
|
|
1579
|
+
query: validation.value,
|
|
1580
|
+
max_results: maxResults,
|
|
1581
|
+
cached: false,
|
|
1582
|
+
counts: {
|
|
1583
|
+
total: results.length,
|
|
1584
|
+
endpoints: grouped.endpoint.length,
|
|
1585
|
+
pages: grouped.page.length,
|
|
1586
|
+
blogs: grouped.blog.length
|
|
1587
|
+
},
|
|
1588
|
+
results,
|
|
1589
|
+
markdown: output
|
|
1590
|
+
};
|
|
1591
|
+
searchCache.set(cacheKey, structuredContent);
|
|
1592
|
+
|
|
1593
|
+
return formatToolSuccess(output, structuredContent);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
case "get_twitterapi_endpoint": {
|
|
1597
|
+
// Validate input
|
|
1598
|
+
const validation = validateEndpointName(args.endpoint_name);
|
|
1599
|
+
if (!validation.valid) {
|
|
1600
|
+
return formatToolError(validation.error);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Check cache first
|
|
1604
|
+
const cacheKey = `endpoint_${validation.value}`;
|
|
1605
|
+
const cachedOutput = endpointCache.get(cacheKey);
|
|
1606
|
+
if (cachedOutput) {
|
|
1607
|
+
logger.info('endpoint', 'Cache hit', { endpoint: validation.value });
|
|
1608
|
+
if (typeof cachedOutput === 'string') {
|
|
1609
|
+
return formatToolSuccess(cachedOutput, {
|
|
1610
|
+
endpoint_name: validation.value,
|
|
1611
|
+
cached: true,
|
|
1612
|
+
markdown: cachedOutput
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
return formatToolSuccess(cachedOutput.markdown, { ...cachedOutput, cached: true });
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const endpoint = data.endpoints?.[validation.value];
|
|
1620
|
+
|
|
1621
|
+
if (!endpoint) {
|
|
1622
|
+
const available = Object.keys(data.endpoints || {});
|
|
1623
|
+
const suggestions = available
|
|
1624
|
+
.filter(e => e.includes(validation.value.split('_')[0]) || validation.value.includes(e.split('_')[0]))
|
|
1625
|
+
.slice(0, 10);
|
|
1626
|
+
|
|
1627
|
+
return formatToolError({
|
|
1628
|
+
type: ErrorType.NOT_FOUND,
|
|
1629
|
+
message: `Endpoint "${validation.value}" not found`,
|
|
1630
|
+
suggestion: suggestions.length > 0
|
|
1631
|
+
? `Similar endpoints: ${suggestions.join(', ')}`
|
|
1632
|
+
: `Use list_twitterapi_endpoints to see all ${available.length} available endpoints`,
|
|
1633
|
+
retryable: false
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const curlExample =
|
|
1638
|
+
endpoint.curl_example ||
|
|
1639
|
+
`curl --request ${endpoint.method || 'GET'} \\
|
|
1640
|
+
--url https://api.twitterapi.io${endpoint.path || ''} \\
|
|
1641
|
+
--header 'x-api-key: YOUR_API_KEY'`;
|
|
1642
|
+
|
|
1643
|
+
const info = `# ${endpoint.title || validation.value}
|
|
1644
|
+
|
|
1645
|
+
## Endpoint Details
|
|
1646
|
+
- **Method:** ${endpoint.method || "GET"}
|
|
1647
|
+
- **Path:** ${endpoint.path || "Unknown"}
|
|
1648
|
+
- **Full URL:** https://api.twitterapi.io${endpoint.path || ""}
|
|
1649
|
+
- **Documentation:** ${endpoint.url}
|
|
1650
|
+
|
|
1651
|
+
## Description
|
|
1652
|
+
${endpoint.description || "No description available."}
|
|
1653
|
+
|
|
1654
|
+
${endpoint.parameters?.length > 0 ? `## Parameters
|
|
1655
|
+
${endpoint.parameters.map(p => `- **${p.name}**${p.required ? ' (required)' : ''}: ${p.description}`).join('\n')}` : ''}
|
|
1656
|
+
|
|
1657
|
+
## cURL Example
|
|
1658
|
+
\`\`\`bash
|
|
1659
|
+
${curlExample}
|
|
1660
|
+
\`\`\`
|
|
1661
|
+
|
|
1662
|
+
${endpoint.code_snippets?.length > 0 ? `## Code Examples
|
|
1663
|
+
\`\`\`
|
|
1664
|
+
${endpoint.code_snippets.join("\n")}
|
|
1665
|
+
\`\`\`` : ""}
|
|
1666
|
+
|
|
1667
|
+
## Full Documentation
|
|
1668
|
+
${endpoint.raw_text || "No additional content available."}`;
|
|
1669
|
+
|
|
1670
|
+
// Cache the result
|
|
1671
|
+
const structuredContent = {
|
|
1672
|
+
endpoint_name: validation.value,
|
|
1673
|
+
title: endpoint.title || validation.value,
|
|
1674
|
+
method: endpoint.method || "GET",
|
|
1675
|
+
path: endpoint.path || "",
|
|
1676
|
+
full_url: `https://api.twitterapi.io${endpoint.path || ""}`,
|
|
1677
|
+
doc_url: endpoint.url || "",
|
|
1678
|
+
description: endpoint.description || "",
|
|
1679
|
+
parameters: endpoint.parameters || [],
|
|
1680
|
+
curl_example: curlExample,
|
|
1681
|
+
code_snippets: endpoint.code_snippets || [],
|
|
1682
|
+
raw_text: endpoint.raw_text || "",
|
|
1683
|
+
cached: false,
|
|
1684
|
+
markdown: info
|
|
1685
|
+
};
|
|
1686
|
+
endpointCache.set(cacheKey, structuredContent);
|
|
1687
|
+
|
|
1688
|
+
return formatToolSuccess(info, structuredContent);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
case "list_twitterapi_endpoints": {
|
|
1692
|
+
// Validate category (optional)
|
|
1693
|
+
const validation = validateCategory(args.category);
|
|
1694
|
+
if (!validation.valid) {
|
|
1695
|
+
return formatToolError(validation.error);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const endpoints = Object.entries(data.endpoints || {});
|
|
1699
|
+
|
|
1700
|
+
const categories = {
|
|
1701
|
+
user: [], tweet: [], list: [], community: [], trend: [],
|
|
1702
|
+
dm: [], action: [], webhook: [], stream: [], other: [],
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
for (const [name, ep] of endpoints) {
|
|
1706
|
+
if (name.includes("user") || name.includes("follow")) {
|
|
1707
|
+
categories.user.push({ name, ...ep });
|
|
1708
|
+
} else if (name.includes("tweet") || name.includes("search") || name.includes("article")) {
|
|
1709
|
+
categories.tweet.push({ name, ...ep });
|
|
1710
|
+
} else if (name.includes("list")) {
|
|
1711
|
+
categories.list.push({ name, ...ep });
|
|
1712
|
+
} else if (name.includes("community")) {
|
|
1713
|
+
categories.community.push({ name, ...ep });
|
|
1714
|
+
} else if (name.includes("trend")) {
|
|
1715
|
+
categories.trend.push({ name, ...ep });
|
|
1716
|
+
} else if (name.includes("dm")) {
|
|
1717
|
+
categories.dm.push({ name, ...ep });
|
|
1718
|
+
} else if (name.includes("webhook") || name.includes("rule")) {
|
|
1719
|
+
categories.webhook.push({ name, ...ep });
|
|
1720
|
+
} else if (name.includes("monitor") || name.includes("stream")) {
|
|
1721
|
+
categories.stream.push({ name, ...ep });
|
|
1722
|
+
} else if (["login", "like", "retweet", "create", "delete", "upload"].some(k => name.includes(k))) {
|
|
1723
|
+
categories.action.push({ name, ...ep });
|
|
1724
|
+
} else {
|
|
1725
|
+
categories.other.push({ name, ...ep });
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const allStructured = [];
|
|
1730
|
+
for (const [cat, eps] of Object.entries(categories)) {
|
|
1731
|
+
for (const ep of eps) {
|
|
1732
|
+
allStructured.push({
|
|
1733
|
+
name: ep.name,
|
|
1734
|
+
method: ep.method || "GET",
|
|
1735
|
+
path: ep.path || "",
|
|
1736
|
+
description: ep.description || "",
|
|
1737
|
+
category: cat
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (validation.value && categories[validation.value]) {
|
|
1743
|
+
const filtered = allStructured.filter((e) => e.category === validation.value);
|
|
1744
|
+
const markdown = `## ${validation.value.toUpperCase()} Endpoints (${filtered.length})
|
|
1745
|
+
|
|
1746
|
+
${filtered.map((e) => `- **${e.name}**: ${e.method} ${e.path}\n ${e.description}`).join("\n\n")}`;
|
|
1747
|
+
return formatToolSuccess(markdown, {
|
|
1748
|
+
category: validation.value,
|
|
1749
|
+
total: endpoints.length,
|
|
1750
|
+
endpoints: filtered,
|
|
1751
|
+
markdown
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
let output = `# TwitterAPI.io Endpoints (Total: ${endpoints.length})\n\n`;
|
|
1756
|
+
for (const [cat, eps] of Object.entries(categories)) {
|
|
1757
|
+
if (eps.length > 0) {
|
|
1758
|
+
output += `## ${cat.toUpperCase()} (${eps.length})\n`;
|
|
1759
|
+
output += eps.map((e) => `- **${e.name}**: ${e.method || "GET"} ${e.path || ""}`).join("\n");
|
|
1760
|
+
output += "\n\n";
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
return formatToolSuccess(output, {
|
|
1764
|
+
category: null,
|
|
1765
|
+
total: endpoints.length,
|
|
1766
|
+
endpoints: allStructured,
|
|
1767
|
+
markdown: output
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
case "get_twitterapi_guide": {
|
|
1772
|
+
// Validate input
|
|
1773
|
+
const validation = validateGuideName(args.guide_name, Object.keys(data.pages || {}));
|
|
1774
|
+
if (!validation.valid) {
|
|
1775
|
+
return formatToolError(validation.error);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const page = data.pages?.[validation.value];
|
|
1779
|
+
|
|
1780
|
+
if (!page) {
|
|
1781
|
+
return formatToolError({
|
|
1782
|
+
type: ErrorType.NOT_FOUND,
|
|
1783
|
+
message: `Guide "${validation.value}" not found`,
|
|
1784
|
+
suggestion: `Available guides: ${Object.keys(data.pages || {}).join(', ')}`,
|
|
1785
|
+
retryable: false
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const output = formatGuideMarkdown(validation.value, page);
|
|
1790
|
+
|
|
1791
|
+
return formatToolSuccess(output, {
|
|
1792
|
+
guide_name: validation.value,
|
|
1793
|
+
title: page.title || validation.value,
|
|
1794
|
+
url: page.url || "",
|
|
1795
|
+
description: page.description || "",
|
|
1796
|
+
headers: page.headers || [],
|
|
1797
|
+
paragraphs: page.paragraphs || [],
|
|
1798
|
+
list_items: page.list_items || [],
|
|
1799
|
+
code_snippets: page.code_snippets || [],
|
|
1800
|
+
raw_text: page.raw_text || "",
|
|
1801
|
+
markdown: output
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
case "get_twitterapi_url": {
|
|
1806
|
+
const rawInput = typeof args.url === 'string' ? args.url.trim() : args.url;
|
|
1807
|
+
const keyCandidate = typeof rawInput === 'string' ? rawInput.toLowerCase() : null;
|
|
1808
|
+
const resolvedInput = keyCandidate && (data.pages?.[keyCandidate]?.url || data.endpoints?.[keyCandidate]?.url || data.blogs?.[keyCandidate]?.url)
|
|
1809
|
+
? (data.pages?.[keyCandidate]?.url || data.endpoints?.[keyCandidate]?.url || data.blogs?.[keyCandidate]?.url)
|
|
1810
|
+
: args.url;
|
|
1811
|
+
|
|
1812
|
+
const validation = validateTwitterApiUrl(resolvedInput);
|
|
1813
|
+
if (!validation.valid) {
|
|
1814
|
+
return formatToolError(validation.error);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const requestedUrl = validation.value;
|
|
1818
|
+
const fetchLive = Boolean(args.fetch_live);
|
|
1819
|
+
|
|
1820
|
+
const snapshotCacheKey = `url_snapshot_${requestedUrl}`;
|
|
1821
|
+
const cachedSnapshot = urlCache.get(snapshotCacheKey);
|
|
1822
|
+
if (cachedSnapshot) {
|
|
1823
|
+
const markdown = `${cachedSnapshot.markdown}\n\n*[Cached result]*`;
|
|
1824
|
+
return formatToolSuccess(markdown, { ...cachedSnapshot, markdown });
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Offline aliases for common redirect routes on docs.twitterapi.io
|
|
1828
|
+
let lookupUrl = requestedUrl;
|
|
1829
|
+
|
|
1830
|
+
if (lookupUrl === 'https://docs.twitterapi.io/') {
|
|
1831
|
+
const introUrl = safeCanonicalizeUrl(data.pages?.introduction?.url) || 'https://docs.twitterapi.io/introduction';
|
|
1832
|
+
lookupUrl = introUrl;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
if (lookupUrl === 'https://docs.twitterapi.io/api-reference' || lookupUrl === 'https://docs.twitterapi.io/api-reference/endpoint') {
|
|
1836
|
+
const listResult = await handleToolCall('list_twitterapi_endpoints', {});
|
|
1837
|
+
const markdown = listResult?.structuredContent?.markdown || listResult?.content?.[0]?.text || '# API Reference';
|
|
1838
|
+
const structuredContent = {
|
|
1839
|
+
url: requestedUrl,
|
|
1840
|
+
source: 'snapshot',
|
|
1841
|
+
kind: 'page',
|
|
1842
|
+
name: 'docs_api_reference',
|
|
1843
|
+
title: 'TwitterAPI.io API Reference',
|
|
1844
|
+
description: 'Index of available endpoints',
|
|
1845
|
+
markdown
|
|
1846
|
+
};
|
|
1847
|
+
urlCache.set(snapshotCacheKey, structuredContent);
|
|
1848
|
+
return formatToolSuccess(markdown, structuredContent);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
const match = findSnapshotItemByUrl(data, lookupUrl);
|
|
1852
|
+
if (match) {
|
|
1853
|
+
const markdown = match.kind === 'endpoint'
|
|
1854
|
+
? formatEndpointMarkdown(match.name, match.item)
|
|
1855
|
+
: formatGuideMarkdown(match.name, match.item);
|
|
1856
|
+
|
|
1857
|
+
const structuredContent = {
|
|
1858
|
+
url: requestedUrl,
|
|
1859
|
+
source: 'snapshot',
|
|
1860
|
+
kind: match.kind,
|
|
1861
|
+
name: match.name,
|
|
1862
|
+
title: match.item?.title || match.name,
|
|
1863
|
+
description: match.item?.description || '',
|
|
1864
|
+
markdown
|
|
1865
|
+
};
|
|
1866
|
+
|
|
1867
|
+
urlCache.set(snapshotCacheKey, structuredContent);
|
|
1868
|
+
return formatToolSuccess(markdown, structuredContent);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
if (!fetchLive) {
|
|
1872
|
+
return formatToolError({
|
|
1873
|
+
type: ErrorType.NOT_FOUND,
|
|
1874
|
+
message: `URL not found in offline snapshot: ${requestedUrl}`,
|
|
1875
|
+
suggestion: 'Run `npm run scrape` to refresh `data/docs.json`, or call again with { fetch_live: true }',
|
|
1876
|
+
retryable: false
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
const liveCacheKey = `url_live_${requestedUrl}`;
|
|
1881
|
+
const cachedLive = urlCache.get(liveCacheKey);
|
|
1882
|
+
if (cachedLive) {
|
|
1883
|
+
const markdown = `${cachedLive.markdown}\n\n*[Cached result]*`;
|
|
1884
|
+
return formatToolSuccess(markdown, { ...cachedLive, markdown });
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
try {
|
|
1888
|
+
const response = await fetch(requestedUrl, { redirect: 'follow' });
|
|
1889
|
+
if (!response.ok) {
|
|
1890
|
+
return formatToolError({
|
|
1891
|
+
type: ErrorType.NOT_FOUND,
|
|
1892
|
+
message: `Failed to fetch URL (${response.status}): ${requestedUrl}`,
|
|
1893
|
+
suggestion: 'Check that the URL is correct and accessible',
|
|
1894
|
+
retryable: response.status >= 500
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
const html = await response.text();
|
|
1899
|
+
const extracted = extractHtmlContent(html);
|
|
1900
|
+
const parsed = new URL(requestedUrl);
|
|
1901
|
+
|
|
1902
|
+
let kind = 'page';
|
|
1903
|
+
let name = 'page';
|
|
1904
|
+
|
|
1905
|
+
if (parsed.hostname === 'docs.twitterapi.io' && parsed.pathname.includes('/api-reference/endpoint/')) {
|
|
1906
|
+
const slug = parsed.pathname.split('/api-reference/endpoint/')[1]?.replace(/\/+$/g, '');
|
|
1907
|
+
if (slug) {
|
|
1908
|
+
kind = 'endpoint';
|
|
1909
|
+
name = slug;
|
|
1910
|
+
}
|
|
1911
|
+
} else if (parsed.hostname === 'twitterapi.io' && parsed.pathname.startsWith('/blog/')) {
|
|
1912
|
+
const slug = parsed.pathname.replace(/^\/blog\//, '');
|
|
1913
|
+
kind = 'blog';
|
|
1914
|
+
name = `blog_${normalizeKeyForName(slug)}`;
|
|
1915
|
+
} else if (parsed.pathname === '/') {
|
|
1916
|
+
name = 'home';
|
|
1917
|
+
} else {
|
|
1918
|
+
name = normalizeKeyForName(parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\//g, '_'));
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
const page = { ...extracted, url: requestedUrl };
|
|
1922
|
+
const markdown = formatGuideMarkdown(name, page);
|
|
1923
|
+
|
|
1924
|
+
const structuredContent = {
|
|
1925
|
+
url: requestedUrl,
|
|
1926
|
+
source: 'live',
|
|
1927
|
+
kind,
|
|
1928
|
+
name,
|
|
1929
|
+
title: extracted.title || name,
|
|
1930
|
+
description: extracted.description || '',
|
|
1931
|
+
markdown
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
urlCache.set(liveCacheKey, structuredContent);
|
|
1935
|
+
return formatToolSuccess(markdown, structuredContent);
|
|
1936
|
+
} catch (error) {
|
|
1937
|
+
logger.error('url_fetch', `Failed to fetch URL: ${requestedUrl}`, error);
|
|
1938
|
+
return formatToolError({
|
|
1939
|
+
type: ErrorType.TIMEOUT,
|
|
1940
|
+
message: 'Failed to fetch URL',
|
|
1941
|
+
suggestion: 'Try again, or run `npm run scrape` to include this page in the offline snapshot',
|
|
1942
|
+
retryable: true
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
case "get_twitterapi_pricing": {
|
|
1948
|
+
const pricing = data.pricing || {};
|
|
1949
|
+
const qps = data.qps_limits || {};
|
|
1950
|
+
|
|
1951
|
+
const notes = [
|
|
1952
|
+
'Credits never expire',
|
|
1953
|
+
'Bonus credits valid for 30 days',
|
|
1954
|
+
'Up to 5% discount on bulk purchases',
|
|
1955
|
+
'List endpoints: 150 credits/request',
|
|
1956
|
+
'~97% cheaper than official Twitter API'
|
|
1957
|
+
];
|
|
1958
|
+
|
|
1959
|
+
const markdown = `# TwitterAPI.io Pricing
|
|
1960
|
+
|
|
1961
|
+
## Credit System
|
|
1962
|
+
- **1 USD = ${pricing.credits_per_usd?.toLocaleString() || "100,000"} Credits**
|
|
1963
|
+
|
|
1964
|
+
## Endpoint Costs
|
|
1965
|
+
${Object.entries(pricing.costs || {}).map(([k, v]) => `- **${k}**: ${v}`).join("\n")}
|
|
1966
|
+
|
|
1967
|
+
## Minimum Charge
|
|
1968
|
+
${pricing.minimum_charge || "15 credits ($0.00015) per request"}
|
|
1969
|
+
|
|
1970
|
+
## QPS (Queries Per Second) Limits
|
|
1971
|
+
|
|
1972
|
+
### Free Users
|
|
1973
|
+
${qps.free || "1 request per 5 seconds"}
|
|
1974
|
+
|
|
1975
|
+
### By Balance Level
|
|
1976
|
+
${Object.entries(qps.paid || {}).map(([k, v]) => `- **${k.replace(/_/g, " ")}**: ${v}`).join("\n")}
|
|
1977
|
+
|
|
1978
|
+
## Important Notes
|
|
1979
|
+
- Credits never expire
|
|
1980
|
+
- Bonus credits valid for 30 days
|
|
1981
|
+
- Up to 5% discount on bulk purchases
|
|
1982
|
+
- List endpoints: 150 credits/request
|
|
1983
|
+
|
|
1984
|
+
## Cost Comparison
|
|
1985
|
+
TwitterAPI.io is **~97% cheaper** than official Twitter API.
|
|
1986
|
+
- Twitter Pro: $5,000/month
|
|
1987
|
+
- TwitterAPI.io equivalent: ~$150/month`;
|
|
1988
|
+
|
|
1989
|
+
return formatToolSuccess(markdown, {
|
|
1990
|
+
credits_per_usd: pricing.credits_per_usd || 100000,
|
|
1991
|
+
minimum_charge: pricing.minimum_charge || "15 credits ($0.00015) per request",
|
|
1992
|
+
costs: pricing.costs || {},
|
|
1993
|
+
qps_limits: {
|
|
1994
|
+
free: qps.free || "1 request per 5 seconds",
|
|
1995
|
+
paid: qps.paid || {}
|
|
1996
|
+
},
|
|
1997
|
+
notes,
|
|
1998
|
+
markdown
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
case "get_twitterapi_auth": {
|
|
2003
|
+
const auth = data.authentication || {};
|
|
2004
|
+
|
|
2005
|
+
const header = auth.header || "x-api-key";
|
|
2006
|
+
const baseUrl = auth.base_url || "https://api.twitterapi.io";
|
|
2007
|
+
const dashboardUrl = auth.dashboard_url || "https://twitterapi.io/dashboard";
|
|
2008
|
+
|
|
2009
|
+
const examples = {
|
|
2010
|
+
curl: `curl -X GET "${baseUrl}/twitter/user/info?userName=elonmusk" \\\n -H "${header}: YOUR_API_KEY"`,
|
|
2011
|
+
python:
|
|
2012
|
+
`import requests\n\nresponse = requests.get(\n "${baseUrl}/twitter/user/info",\n params={"userName": "elonmusk"},\n headers={"${header}": "YOUR_API_KEY"}\n)\nprint(response.json())`,
|
|
2013
|
+
javascript:
|
|
2014
|
+
`const response = await fetch(\n "${baseUrl}/twitter/user/info?userName=elonmusk",\n { headers: { "${header}": "YOUR_API_KEY" } }\n);\nconst data = await response.json();`
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
const markdown = `# TwitterAPI.io Authentication
|
|
2018
|
+
|
|
2019
|
+
## API Key Usage
|
|
2020
|
+
All requests require the \`${header}\` header.
|
|
2021
|
+
|
|
2022
|
+
## Base URL
|
|
2023
|
+
\`${baseUrl}\`
|
|
2024
|
+
|
|
2025
|
+
## Getting Your API Key
|
|
2026
|
+
1. Go to ${dashboardUrl}
|
|
2027
|
+
2. Sign up / Log in
|
|
2028
|
+
3. Copy your API key from the dashboard
|
|
2029
|
+
|
|
2030
|
+
## Request Examples
|
|
2031
|
+
|
|
2032
|
+
### cURL
|
|
2033
|
+
\`\`\`bash
|
|
2034
|
+
${examples.curl}
|
|
2035
|
+
\`\`\`
|
|
2036
|
+
|
|
2037
|
+
### Python
|
|
2038
|
+
\`\`\`python
|
|
2039
|
+
${examples.python}
|
|
2040
|
+
\`\`\`
|
|
2041
|
+
|
|
2042
|
+
### JavaScript
|
|
2043
|
+
\`\`\`javascript
|
|
2044
|
+
${examples.javascript}
|
|
2045
|
+
\`\`\``;
|
|
2046
|
+
|
|
2047
|
+
return formatToolSuccess(markdown, {
|
|
2048
|
+
header,
|
|
2049
|
+
base_url: baseUrl,
|
|
2050
|
+
dashboard_url: dashboardUrl,
|
|
2051
|
+
examples,
|
|
2052
|
+
markdown
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
default:
|
|
2057
|
+
return formatToolError({
|
|
2058
|
+
type: ErrorType.NOT_FOUND,
|
|
2059
|
+
message: `Unknown tool: ${name}`,
|
|
2060
|
+
suggestion: 'Available tools: search_twitterapi_docs, get_twitterapi_endpoint, list_twitterapi_endpoints, get_twitterapi_guide, get_twitterapi_url, get_twitterapi_pricing, get_twitterapi_auth',
|
|
2061
|
+
retryable: false
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// ========== RESOURCES ==========
|
|
2067
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
2068
|
+
resources: [
|
|
2069
|
+
// Documentation resources
|
|
2070
|
+
{
|
|
2071
|
+
uri: "twitterapi://docs/all",
|
|
2072
|
+
mimeType: "application/json",
|
|
2073
|
+
name: "All TwitterAPI.io Documentation",
|
|
2074
|
+
description: "52 endpoints + guide pages + blog posts",
|
|
2075
|
+
},
|
|
2076
|
+
{
|
|
2077
|
+
uri: "twitterapi://docs/endpoints",
|
|
2078
|
+
mimeType: "application/json",
|
|
2079
|
+
name: "API Endpoint List",
|
|
2080
|
+
description: "Summary of all API endpoints",
|
|
2081
|
+
},
|
|
2082
|
+
{
|
|
2083
|
+
uri: "twitterapi://endpoints/list",
|
|
2084
|
+
mimeType: "application/json",
|
|
2085
|
+
name: "API Endpoints (Alias)",
|
|
2086
|
+
description: "Alias of twitterapi://docs/endpoints",
|
|
2087
|
+
},
|
|
2088
|
+
{
|
|
2089
|
+
uri: "twitterapi://docs/guides",
|
|
2090
|
+
mimeType: "application/json",
|
|
2091
|
+
name: "Guide Pages",
|
|
2092
|
+
description: "Pricing, QPS limits, filter rules, etc.",
|
|
2093
|
+
},
|
|
2094
|
+
// Static guide resources (Phase 2)
|
|
2095
|
+
{
|
|
2096
|
+
uri: "twitterapi://guides/pricing",
|
|
2097
|
+
mimeType: "text/markdown",
|
|
2098
|
+
name: "Pricing Guide",
|
|
2099
|
+
description: "Credit system, endpoint costs, QPS limits",
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
uri: "twitterapi://guides/authentication",
|
|
2103
|
+
mimeType: "text/markdown",
|
|
2104
|
+
name: "Authentication Guide",
|
|
2105
|
+
description: "API key setup, headers, code examples",
|
|
2106
|
+
},
|
|
2107
|
+
{
|
|
2108
|
+
uri: "twitterapi://guides/qps_limits",
|
|
2109
|
+
mimeType: "text/markdown",
|
|
2110
|
+
name: "Rate Limits Guide",
|
|
2111
|
+
description: "QPS limits by balance level",
|
|
2112
|
+
},
|
|
2113
|
+
{
|
|
2114
|
+
uri: "twitterapi://guides/qps-limits",
|
|
2115
|
+
mimeType: "text/markdown",
|
|
2116
|
+
name: "Rate Limits Guide (Alias)",
|
|
2117
|
+
description: "Alias of twitterapi://guides/qps_limits",
|
|
2118
|
+
},
|
|
2119
|
+
{
|
|
2120
|
+
uri: "twitterapi://guides/tweet_filter_rules",
|
|
2121
|
+
mimeType: "text/markdown",
|
|
2122
|
+
name: "Tweet Filter Rules",
|
|
2123
|
+
description: "Advanced search filter syntax",
|
|
2124
|
+
},
|
|
2125
|
+
{
|
|
2126
|
+
uri: "twitterapi://guides/filter-rules",
|
|
2127
|
+
mimeType: "text/markdown",
|
|
2128
|
+
name: "Tweet Filter Rules (Alias)",
|
|
2129
|
+
description: "Alias of twitterapi://guides/tweet_filter_rules",
|
|
2130
|
+
},
|
|
2131
|
+
{
|
|
2132
|
+
uri: "twitterapi://guides/changelog",
|
|
2133
|
+
mimeType: "text/markdown",
|
|
2134
|
+
name: "Changelog",
|
|
2135
|
+
description: "API changelog",
|
|
2136
|
+
},
|
|
2137
|
+
{
|
|
2138
|
+
uri: "twitterapi://guides/introduction",
|
|
2139
|
+
mimeType: "text/markdown",
|
|
2140
|
+
name: "Introduction",
|
|
2141
|
+
description: "Overview of TwitterAPI.io",
|
|
2142
|
+
},
|
|
2143
|
+
{
|
|
2144
|
+
uri: "twitterapi://guides/readme",
|
|
2145
|
+
mimeType: "text/markdown",
|
|
2146
|
+
name: "README",
|
|
2147
|
+
description: "Project overview and usage",
|
|
2148
|
+
},
|
|
2149
|
+
// Monitoring resources
|
|
2150
|
+
{
|
|
2151
|
+
uri: "twitterapi://metrics",
|
|
2152
|
+
mimeType: "application/json",
|
|
2153
|
+
name: "Server Metrics",
|
|
2154
|
+
description: "Performance metrics, SLO tracking, cache stats",
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
uri: "twitterapi://health",
|
|
2158
|
+
mimeType: "application/json",
|
|
2159
|
+
name: "Health Check",
|
|
2160
|
+
description: "Server health status and data freshness",
|
|
2161
|
+
},
|
|
2162
|
+
{
|
|
2163
|
+
uri: "twitterapi://status/freshness",
|
|
2164
|
+
mimeType: "application/json",
|
|
2165
|
+
name: "Data Freshness",
|
|
2166
|
+
description: "Last docs update time and freshness status",
|
|
2167
|
+
},
|
|
2168
|
+
],
|
|
2169
|
+
}));
|
|
2170
|
+
|
|
2171
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
2172
|
+
const { uri } = request.params;
|
|
2173
|
+
const data = loadDocs();
|
|
2174
|
+
|
|
2175
|
+
// Documentation resources
|
|
2176
|
+
if (uri === "twitterapi://docs/all") {
|
|
2177
|
+
return {
|
|
2178
|
+
contents: [{
|
|
2179
|
+
uri,
|
|
2180
|
+
mimeType: "application/json",
|
|
2181
|
+
text: JSON.stringify(data, null, 2),
|
|
2182
|
+
}],
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
if (uri === "twitterapi://docs/endpoints" || uri === "twitterapi://endpoints/list") {
|
|
2187
|
+
const summary = Object.entries(data.endpoints || {}).map(([name, ep]) => ({
|
|
2188
|
+
name,
|
|
2189
|
+
method: ep.method,
|
|
2190
|
+
path: ep.path,
|
|
2191
|
+
description: ep.description,
|
|
2192
|
+
}));
|
|
2193
|
+
return {
|
|
2194
|
+
contents: [{
|
|
2195
|
+
uri,
|
|
2196
|
+
mimeType: "application/json",
|
|
2197
|
+
text: JSON.stringify(summary, null, 2),
|
|
2198
|
+
}],
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
if (uri === "twitterapi://docs/guides") {
|
|
2203
|
+
return {
|
|
2204
|
+
contents: [{
|
|
2205
|
+
uri,
|
|
2206
|
+
mimeType: "application/json",
|
|
2207
|
+
text: JSON.stringify({ pages: data.pages, blogs: data.blogs }, null, 2),
|
|
2208
|
+
}],
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// Static guide resources (Phase 2 - pre-rendered markdown for quick access)
|
|
2213
|
+
if (uri === "twitterapi://guides/pricing") {
|
|
2214
|
+
const pricing = data.pricing || {};
|
|
2215
|
+
const qps = data.qps_limits || {};
|
|
2216
|
+
return {
|
|
2217
|
+
contents: [{
|
|
2218
|
+
uri,
|
|
2219
|
+
mimeType: "text/markdown",
|
|
2220
|
+
text: `# TwitterAPI.io Pricing
|
|
2221
|
+
|
|
2222
|
+
## Credit System
|
|
2223
|
+
- **1 USD = ${pricing.credits_per_usd?.toLocaleString() || "100,000"} Credits**
|
|
2224
|
+
|
|
2225
|
+
## Endpoint Costs
|
|
2226
|
+
${Object.entries(pricing.costs || {}).map(([k, v]) => `- **${k}**: ${v}`).join("\n")}
|
|
2227
|
+
|
|
2228
|
+
## QPS Limits by Balance Level
|
|
2229
|
+
${Object.entries(qps.paid || {}).map(([k, v]) => `- **${k.replace(/_/g, " ")}**: ${v}`).join("\n")}
|
|
2230
|
+
|
|
2231
|
+
## Important Notes
|
|
2232
|
+
- Credits never expire
|
|
2233
|
+
- Bonus credits valid for 30 days
|
|
2234
|
+
- ~97% cheaper than official Twitter API`,
|
|
2235
|
+
}],
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
if (uri === "twitterapi://guides/authentication") {
|
|
2240
|
+
const auth = data.authentication || {};
|
|
2241
|
+
return {
|
|
2242
|
+
contents: [{
|
|
2243
|
+
uri,
|
|
2244
|
+
mimeType: "text/markdown",
|
|
2245
|
+
text: `# TwitterAPI.io Authentication
|
|
2246
|
+
|
|
2247
|
+
## API Key Header
|
|
2248
|
+
\`${auth.header || "x-api-key"}: YOUR_API_KEY\`
|
|
2249
|
+
|
|
2250
|
+
## Base URL
|
|
2251
|
+
\`${auth.base_url || "https://api.twitterapi.io"}\`
|
|
2252
|
+
|
|
2253
|
+
## Quick Example
|
|
2254
|
+
\`\`\`bash
|
|
2255
|
+
curl -X GET "${auth.base_url || "https://api.twitterapi.io"}/twitter/user/info?userName=elonmusk" \\
|
|
2256
|
+
-H "${auth.header || "x-api-key"}: YOUR_API_KEY"
|
|
2257
|
+
\`\`\``,
|
|
2258
|
+
}],
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
if (uri === "twitterapi://guides/qps_limits" || uri === "twitterapi://guides/qps-limits") {
|
|
2263
|
+
const qps = data.qps_limits || {};
|
|
2264
|
+
return {
|
|
2265
|
+
contents: [{
|
|
2266
|
+
uri,
|
|
2267
|
+
mimeType: "text/markdown",
|
|
2268
|
+
text: `# TwitterAPI.io Rate Limits (QPS)
|
|
2269
|
+
|
|
2270
|
+
## Free Users
|
|
2271
|
+
${qps.free || "1 request per 5 seconds"}
|
|
2272
|
+
|
|
2273
|
+
## Paid Users by Balance
|
|
2274
|
+
${Object.entries(qps.paid || {}).map(([k, v]) => `- **${k.replace(/_/g, " ")}**: ${v}`).join("\n")}`,
|
|
2275
|
+
}],
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
if (uri === "twitterapi://guides/tweet_filter_rules" || uri === "twitterapi://guides/filter-rules") {
|
|
2280
|
+
const page = data.pages?.tweet_filter_rules || {};
|
|
2281
|
+
return {
|
|
2282
|
+
contents: [{
|
|
2283
|
+
uri,
|
|
2284
|
+
mimeType: "text/markdown",
|
|
2285
|
+
text: `# Tweet Filter Rules
|
|
2286
|
+
|
|
2287
|
+
${page.raw_text || page.description || "Filter rules documentation not available."}`,
|
|
2288
|
+
}],
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (uri === "twitterapi://guides/changelog") {
|
|
2293
|
+
const page = data.pages?.changelog || {};
|
|
2294
|
+
return {
|
|
2295
|
+
contents: [{
|
|
2296
|
+
uri,
|
|
2297
|
+
mimeType: "text/markdown",
|
|
2298
|
+
text: `# ${page.title || "Changelog"}
|
|
2299
|
+
|
|
2300
|
+
${page.raw_text || page.description || "Changelog not available."}`,
|
|
2301
|
+
}],
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
if (uri === "twitterapi://guides/introduction") {
|
|
2306
|
+
const page = data.pages?.introduction || {};
|
|
2307
|
+
return {
|
|
2308
|
+
contents: [{
|
|
2309
|
+
uri,
|
|
2310
|
+
mimeType: "text/markdown",
|
|
2311
|
+
text: `# ${page.title || "Introduction"}
|
|
2312
|
+
|
|
2313
|
+
${page.raw_text || page.description || "Introduction not available."}`,
|
|
2314
|
+
}],
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
if (uri === "twitterapi://guides/readme") {
|
|
2319
|
+
const page = data.pages?.readme || {};
|
|
2320
|
+
return {
|
|
2321
|
+
contents: [{
|
|
2322
|
+
uri,
|
|
2323
|
+
mimeType: "text/markdown",
|
|
2324
|
+
text: `# ${page.title || "README"}
|
|
2325
|
+
|
|
2326
|
+
${page.raw_text || page.description || "README not available."}`,
|
|
2327
|
+
}],
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Monitoring resources
|
|
2332
|
+
if (uri === "twitterapi://metrics") {
|
|
2333
|
+
return {
|
|
2334
|
+
contents: [{
|
|
2335
|
+
uri,
|
|
2336
|
+
mimeType: "application/json",
|
|
2337
|
+
text: JSON.stringify(logger.getMetrics(getAllCacheStats(), getDataFreshness()), null, 2),
|
|
2338
|
+
}],
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
if (uri === "twitterapi://health") {
|
|
2343
|
+
const freshness = getDataFreshness();
|
|
2344
|
+
const health = {
|
|
2345
|
+
status: freshness.status === 'stale' ? 'degraded' : 'healthy',
|
|
2346
|
+
timestamp: new Date().toISOString(),
|
|
2347
|
+
uptime: process.uptime(),
|
|
2348
|
+
dataFreshness: freshness,
|
|
2349
|
+
cache: {
|
|
2350
|
+
search: searchCache.stats(),
|
|
2351
|
+
endpoints: endpointCache.stats(),
|
|
2352
|
+
urls: urlCache.stats()
|
|
2353
|
+
},
|
|
2354
|
+
sloStatus: {
|
|
2355
|
+
violations: logger.metrics.sloViolations,
|
|
2356
|
+
healthy: logger.metrics.sloViolations.alert === 0
|
|
2357
|
+
}
|
|
2358
|
+
};
|
|
2359
|
+
return {
|
|
2360
|
+
contents: [{
|
|
2361
|
+
uri,
|
|
2362
|
+
mimeType: "application/json",
|
|
2363
|
+
text: JSON.stringify(health, null, 2),
|
|
2364
|
+
}],
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
if (uri === "twitterapi://status/freshness") {
|
|
2369
|
+
return {
|
|
2370
|
+
contents: [{
|
|
2371
|
+
uri,
|
|
2372
|
+
mimeType: "application/json",
|
|
2373
|
+
text: JSON.stringify(getDataFreshness(), null, 2),
|
|
2374
|
+
}],
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
// ========== COMPLETIONS HANDLER (for Glama.ai compatibility) ==========
|
|
2382
|
+
server.setRequestHandler(CompleteRequestSchema, async () => {
|
|
2383
|
+
// Return empty completions - we don't provide autocomplete suggestions
|
|
2384
|
+
// but declaring the capability allows mcp-proxy to work correctly
|
|
2385
|
+
return {
|
|
2386
|
+
completion: {
|
|
2387
|
+
values: [],
|
|
2388
|
+
hasMore: false,
|
|
2389
|
+
total: 0
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
// ========== SERVER STARTUP ==========
|
|
2395
|
+
async function main() {
|
|
2396
|
+
try {
|
|
2397
|
+
logger.info('init', 'Starting TwitterAPI.io Docs MCP Server v1.0.9');
|
|
2398
|
+
|
|
2399
|
+
// Validate docs file exists
|
|
2400
|
+
if (!fs.existsSync(DOCS_PATH)) {
|
|
2401
|
+
throw new Error(`Documentation file not found: ${DOCS_PATH}`);
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// Pre-load documentation
|
|
2405
|
+
const docs = loadDocs();
|
|
2406
|
+
const endpointCount = Object.keys(docs.endpoints || {}).length;
|
|
2407
|
+
const pageCount = Object.keys(docs.pages || {}).length;
|
|
2408
|
+
logger.info('init', 'Documentation validated', { endpoints: endpointCount, pages: pageCount });
|
|
2409
|
+
|
|
2410
|
+
// Check data freshness
|
|
2411
|
+
const freshness = getDataFreshness();
|
|
2412
|
+
logger.info('init', 'Data freshness check', freshness);
|
|
2413
|
+
if (freshness.status === 'stale') {
|
|
2414
|
+
logger.warn('init', 'WARNING: Documentation data is stale! Consider refreshing.');
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// Start cache cleanup scheduler
|
|
2418
|
+
startCacheCleanup();
|
|
2419
|
+
logger.info('init', 'Cache cleanup scheduler started (hourly)');
|
|
2420
|
+
|
|
2421
|
+
// Log SLO configuration
|
|
2422
|
+
logger.info('init', 'SLO targets configured', {
|
|
2423
|
+
tools: Object.keys(SLO).length,
|
|
2424
|
+
targets: Object.entries(SLO).map(([t, s]) => `${t}: ${s.target}ms`)
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
// Connect transport
|
|
2428
|
+
const transport = new StdioServerTransport();
|
|
2429
|
+
await server.connect(transport);
|
|
2430
|
+
|
|
2431
|
+
logger.info('init', 'MCP Server ready on stdio', {
|
|
2432
|
+
version: '1.0.9',
|
|
2433
|
+
features: [
|
|
2434
|
+
'offline snapshot',
|
|
2435
|
+
'endpoints + pages + blogs',
|
|
2436
|
+
'get_twitterapi_url (optional live fetch)',
|
|
2437
|
+
'structuredContent outputs',
|
|
2438
|
+
'MCP Resources',
|
|
2439
|
+
'data freshness',
|
|
2440
|
+
'trusted publishing'
|
|
2441
|
+
]
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
// Graceful shutdown
|
|
2445
|
+
process.on('SIGINT', () => {
|
|
2446
|
+
logger.info('shutdown', 'Received SIGINT, shutting down...');
|
|
2447
|
+
stopCacheCleanup();
|
|
2448
|
+
logger.info('shutdown', 'Final metrics', logger.getMetrics(getAllCacheStats(), getDataFreshness()));
|
|
2449
|
+
process.exit(0);
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
process.on('SIGTERM', () => {
|
|
2453
|
+
logger.info('shutdown', 'Received SIGTERM, shutting down...');
|
|
2454
|
+
stopCacheCleanup();
|
|
2455
|
+
logger.info('shutdown', 'Final metrics', logger.getMetrics(getAllCacheStats(), getDataFreshness()));
|
|
2456
|
+
process.exit(0);
|
|
2457
|
+
});
|
|
2458
|
+
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
logger.error('init', 'Fatal error during initialization', error);
|
|
2461
|
+
process.exit(1);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
main().catch((error) => {
|
|
2466
|
+
console.error('[FATAL] Unexpected error:', error);
|
|
2467
|
+
process.exit(1);
|
|
2468
|
+
});
|