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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/data/docs.json +6240 -0
  4. package/index.js +2468 -0
  5. 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(/&#x3C;/g, '<')
971
+ .replace(/&#x3E;/g, '>')
972
+ .replace(/&#x27;/g, "'")
973
+ .replace(/&quot;/g, '"')
974
+ .replace(/&amp;/g, '&')
975
+ .replace(/&lt;/g, '<')
976
+ .replace(/&gt;/g, '>')
977
+ .replace(/&#39;/g, "'")
978
+ .replace(/&nbsp;/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
+ });