Skip to main content
Glama
index.js53.2 kB
#!/usr/bin/env node /** * TwitterAPI.io Documentation MCP Server v1.0.3 * * Production-ready MCP server with: * - Comprehensive error handling with ErrorType classification * - Input validation for all tools * - Structured logging with metrics * - LLM-optimized tool descriptions with output schemas * - Performance monitoring with SLO tracking * - Hybrid cache (memory + disk) for search and endpoints * - MCP Resources for static guide access * - Data freshness monitoring * * v3.3 Improvements (Phase 2): * - max_results parameter for search (1-20, default 10) * - Advanced tokenization with camelCase support * - Per-tool latency SLO tracking with alerts * - Enhanced MCP Resources for static guides * - Data freshness monitoring (24h staleness warning) * * v3.2 Improvements: * - Output schemas for all tools (helps LLM parse responses) * * v3.1 Improvements: * - HybridCache with LRU eviction and TTL expiry * - Memory-first caching with disk persistence for stdio MCP * - Automatic hourly cache cleanup * - Cache stats in metrics resource * * v3.0 Improvements: * - Error handling with suggestions for LLM * - Input validation (query length, pattern matching) * - Structured logging with latency tracking * - Better tool descriptions for LLM decision making */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, CompleteRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOCS_PATH = path.join(__dirname, "data", "docs.json"); // ========== ERROR HANDLING ========== const ErrorType = { INPUT_VALIDATION: 'input_validation', NOT_FOUND: 'not_found', INTERNAL_ERROR: 'internal_error', TIMEOUT: 'timeout', }; function formatToolError(error) { return { content: [{ type: 'text', text: `Error: ${error.message}${error.suggestion ? '\n\nSuggestion: ' + error.suggestion : ''}` }], isError: true }; } function formatToolSuccess(text) { return { content: [{ type: 'text', text }], isError: false }; } // ========== STRUCTURED LOGGING ========== const LogLevel = { DEBUG: 'DEBUG', INFO: 'INFO', WARN: 'WARN', ERROR: 'ERROR' }; // Service Level Objectives (SLOs) - latency targets in ms const SLO = { search_twitterapi_docs: { target: 50, acceptable: 100, alert: 200 }, get_twitterapi_endpoint: { target: 10, acceptable: 50, alert: 100 }, list_twitterapi_endpoints: { target: 5, acceptable: 20, alert: 50 }, get_twitterapi_guide: { target: 10, acceptable: 50, alert: 100 }, get_twitterapi_pricing: { target: 5, acceptable: 20, alert: 50 }, get_twitterapi_auth: { target: 5, acceptable: 20, alert: 50 } }; class Logger { constructor() { this.logs = []; this.MAX_LOGS = 10000; this.metrics = { requests: { total: 0, successful: 0, failed: 0, totalLatency: 0 }, cache: { hits: 0, misses: 0 }, tools: {}, sloViolations: { target: 0, acceptable: 0, alert: 0 } }; } log(level, component, message, data = null) { const entry = { timestamp: new Date().toISOString(), level, component, message, data }; this.logs.push(entry); if (this.logs.length > this.MAX_LOGS) { this.logs.shift(); } // Output to stderr (MCP standard - stdout is for protocol) const prefix = `[${entry.timestamp}] [${level}] [${component}]`; console.error(`${prefix} ${message}`, data ? JSON.stringify(data) : ''); } info(component, message, data) { this.log(LogLevel.INFO, component, message, data); } warn(component, message, data) { this.log(LogLevel.WARN, component, message, data); } error(component, message, error) { this.log(LogLevel.ERROR, component, message, { error: error?.message, stack: error?.stack?.split('\n').slice(0, 3) }); } recordToolCall(toolName, duration, success) { this.metrics.requests.total++; this.metrics.requests.totalLatency += duration; if (success) { this.metrics.requests.successful++; } else { this.metrics.requests.failed++; } if (!this.metrics.tools[toolName]) { this.metrics.tools[toolName] = { calls: 0, errors: 0, totalDuration: 0, minLatency: Infinity, maxLatency: 0, sloViolations: { target: 0, acceptable: 0, alert: 0 } }; } const tool = this.metrics.tools[toolName]; tool.calls++; tool.totalDuration += duration; tool.minLatency = Math.min(tool.minLatency, duration); tool.maxLatency = Math.max(tool.maxLatency, duration); if (!success) { tool.errors++; } // Track SLO violations const slo = SLO[toolName]; if (slo) { if (duration > slo.alert) { tool.sloViolations.alert++; this.metrics.sloViolations.alert++; this.warn('slo', `ALERT: ${toolName} exceeded alert threshold`, { duration, threshold: slo.alert, severity: 'alert' }); } else if (duration > slo.acceptable) { tool.sloViolations.acceptable++; this.metrics.sloViolations.acceptable++; this.warn('slo', `${toolName} exceeded acceptable threshold`, { duration, threshold: slo.acceptable, severity: 'acceptable' }); } else if (duration > slo.target) { tool.sloViolations.target++; this.metrics.sloViolations.target++; } } } recordCacheHit() { this.metrics.cache.hits++; } recordCacheMiss() { this.metrics.cache.misses++; } getMetrics(cacheStats = null, dataFreshness = null) { const avgLatency = this.metrics.requests.total > 0 ? Math.round(this.metrics.requests.totalLatency / this.metrics.requests.total) : 0; const cacheTotal = this.metrics.cache.hits + this.metrics.cache.misses; const cacheHitRate = cacheTotal > 0 ? (this.metrics.cache.hits / cacheTotal * 100).toFixed(1) : 0; const result = { timestamp: new Date().toISOString(), uptime: process.uptime(), requests: { ...this.metrics.requests, averageLatency: avgLatency }, cache: { ...this.metrics.cache, hitRate: `${cacheHitRate}%` }, sloViolations: this.metrics.sloViolations, tools: Object.entries(this.metrics.tools).reduce((acc, [tool, data]) => { const slo = SLO[tool]; acc[tool] = { calls: data.calls, errors: data.errors, latency: { avg: Math.round(data.totalDuration / data.calls), min: data.minLatency === Infinity ? 0 : data.minLatency, max: data.maxLatency }, slo: slo ? { target: `${slo.target}ms`, acceptable: `${slo.acceptable}ms`, alert: `${slo.alert}ms`, violations: data.sloViolations } : null }; return acc; }, {}) }; // Add hybrid cache stats if provided if (cacheStats) { result.hybridCaches = cacheStats; } // Add data freshness if provided if (dataFreshness) { result.dataFreshness = dataFreshness; } return result; } } const logger = new Logger(); // ========== HYBRID CACHE ========== const CACHE_DIR = path.join(__dirname, "cache"); class HybridCache { constructor(name, options = {}) { this.name = name; this.memory = new Map(); this.MAX_MEMORY = options.maxEntries || 500; this.DEFAULT_TTL = options.ttl || 24 * 60 * 60 * 1000; // 24 hours this.DISK_WRITE_PROBABILITY = options.diskWriteProbability || 0.1; // 10% disk writes this.diskDir = path.join(CACHE_DIR, name); this.ensureDir(); } ensureDir() { try { if (!fs.existsSync(CACHE_DIR)) { fs.mkdirSync(CACHE_DIR, { recursive: true }); } if (!fs.existsSync(this.diskDir)) { fs.mkdirSync(this.diskDir, { recursive: true }); } } catch (err) { logger.warn('cache', `Failed to create cache directory: ${err.message}`); } } normalizeKey(key) { return key.toLowerCase().replace(/[^a-z0-9]/g, '_').slice(0, 100); } isExpired(entry) { return Date.now() - entry.timestamp > entry.ttl; } get(key) { const normalized = this.normalizeKey(key); // Check memory first const memEntry = this.memory.get(normalized); if (memEntry && !this.isExpired(memEntry)) { logger.recordCacheHit(); return memEntry.value; } this.memory.delete(normalized); // Check disk try { const diskPath = path.join(this.diskDir, `${normalized}.json`); if (fs.existsSync(diskPath)) { const diskEntry = JSON.parse(fs.readFileSync(diskPath, 'utf-8')); if (!this.isExpired(diskEntry)) { // Restore to memory this.memory.set(normalized, diskEntry); logger.recordCacheHit(); logger.info('cache', `Restored from disk: ${this.name}/${normalized}`); return diskEntry.value; } // Clean up expired disk entry fs.unlinkSync(diskPath); } } catch (err) { // Disk read failed, continue gracefully } logger.recordCacheMiss(); return null; } set(key, value, ttl = this.DEFAULT_TTL) { const normalized = this.normalizeKey(key); const entry = { key: normalized, value, timestamp: Date.now(), ttl }; // Store in memory this.memory.set(normalized, entry); logger.info('cache', `Memory write: ${this.name}/${normalized}`, { diskProb: this.DISK_WRITE_PROBABILITY }); // Evict oldest if over capacity (LRU-like) if (this.memory.size > this.MAX_MEMORY) { const oldestKey = this.memory.keys().next().value; this.memory.delete(oldestKey); } // Write to disk (always for stdio MCP servers) if (Math.random() < this.DISK_WRITE_PROBABILITY) { this.writeToDisk(normalized, entry); } } writeToDisk(key, entry) { try { const diskPath = path.join(this.diskDir, `${key}.json`); fs.writeFileSync(diskPath, JSON.stringify(entry, null, 2)); logger.info('cache', `Disk write success: ${this.name}/${key}`); } catch (err) { logger.warn('cache', `Disk write failed: ${err.message}`, { path: this.diskDir, key }); } } cleanup() { let memoryCleared = 0; let diskCleared = 0; // Memory cleanup for (const [key, entry] of this.memory.entries()) { if (this.isExpired(entry)) { this.memory.delete(key); memoryCleared++; } } // Disk cleanup try { const files = fs.readdirSync(this.diskDir); for (const file of files) { try { const diskPath = path.join(this.diskDir, file); const entry = JSON.parse(fs.readFileSync(diskPath, 'utf-8')); if (this.isExpired(entry)) { fs.unlinkSync(diskPath); diskCleared++; } } catch (err) { // Skip invalid files } } } catch (err) { // Disk cleanup failed, continue } if (memoryCleared > 0 || diskCleared > 0) { logger.info('cache', `Cleanup: ${memoryCleared} memory, ${diskCleared} disk entries removed`); } } stats() { let diskEntries = 0; try { diskEntries = fs.readdirSync(this.diskDir).length; } catch (err) { // Ignore } return { name: this.name, memoryEntries: this.memory.size, diskEntries, maxMemory: this.MAX_MEMORY }; } } // Initialize caches // Note: For stdio MCP servers (spawned per-call), use higher disk probability // Memory cache is within-session, disk cache persists across sessions const searchCache = new HybridCache('search', { maxEntries: 200, ttl: 6 * 60 * 60 * 1000, // 6 hours for search diskWriteProbability: 1.0 // Always write to disk for stdio MCP }); const endpointCache = new HybridCache('endpoints', { maxEntries: 100, ttl: 24 * 60 * 60 * 1000, // 24 hours for endpoints diskWriteProbability: 1.0 // Always write to disk for stdio MCP }); // Periodic cleanup (every hour) let cleanupInterval = null; function startCacheCleanup() { if (cleanupInterval) return; cleanupInterval = setInterval(() => { searchCache.cleanup(); endpointCache.cleanup(); }, 60 * 60 * 1000); // 1 hour } function stopCacheCleanup() { if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; } } function getAllCacheStats() { return { search: searchCache.stats(), endpoints: endpointCache.stats() }; } // ========== INPUT VALIDATION ========== const VALIDATION = { QUERY_MAX_LENGTH: 500, QUERY_MIN_LENGTH: 1, ENDPOINT_PATTERN: /^[a-zA-Z0-9_\-]+$/, GUIDE_NAMES: ['pricing', 'qps_limits', 'tweet_filter_rules', 'changelog', 'introduction', 'authentication', 'readme'], CATEGORIES: ['user', 'tweet', 'community', 'webhook', 'stream', 'action', 'dm', 'list', 'trend', 'other'] }; function validateQuery(query) { if (!query || typeof query !== 'string') { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: 'Query cannot be empty', suggestion: 'Try: "user info", "advanced search", "rate limits", "webhook"', retryable: false } }; } const trimmed = query.trim(); if (trimmed.length < VALIDATION.QUERY_MIN_LENGTH) { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: 'Query too short', suggestion: 'Enter at least 1 character. Examples: "tweet", "user", "search"', retryable: false } }; } if (trimmed.length > VALIDATION.QUERY_MAX_LENGTH) { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: `Query too long (${trimmed.length} chars, max ${VALIDATION.QUERY_MAX_LENGTH})`, suggestion: 'Use fewer, more specific keywords', retryable: false } }; } return { valid: true, value: trimmed }; } function validateEndpointName(name) { if (!name || typeof name !== 'string') { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: 'Endpoint name cannot be empty', suggestion: 'Use list_twitterapi_endpoints to see available endpoints', retryable: false } }; } const trimmed = name.trim(); if (!VALIDATION.ENDPOINT_PATTERN.test(trimmed)) { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: 'Invalid endpoint name format', suggestion: 'Use format like: get_user_info, tweet_advanced_search, add_webhook_rule', retryable: false } }; } return { valid: true, value: trimmed }; } function validateGuideName(name) { if (!name || typeof name !== 'string') { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: 'Guide name cannot be empty', suggestion: `Available guides: ${VALIDATION.GUIDE_NAMES.join(', ')}`, retryable: false } }; } const trimmed = name.trim().toLowerCase(); if (!VALIDATION.GUIDE_NAMES.includes(trimmed)) { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: `Unknown guide: "${trimmed}"`, suggestion: `Available guides: ${VALIDATION.GUIDE_NAMES.join(', ')}`, retryable: false } }; } return { valid: true, value: trimmed }; } function validateCategory(category) { if (!category) { return { valid: true, value: null }; // Optional parameter } const trimmed = category.trim().toLowerCase(); if (!VALIDATION.CATEGORIES.includes(trimmed)) { return { valid: false, error: { type: ErrorType.INPUT_VALIDATION, message: `Unknown category: "${trimmed}"`, suggestion: `Available categories: ${VALIDATION.CATEGORIES.join(', ')}`, retryable: false } }; } return { valid: true, value: trimmed }; } // ========== DATA LOADING ========== let cachedDocs = null; let lastModified = 0; // Data freshness configuration const DATA_FRESHNESS = { WARNING_THRESHOLD: 24 * 60 * 60 * 1000, // 24 hours STALE_THRESHOLD: 72 * 60 * 60 * 1000, // 72 hours }; function loadDocs() { try { const stat = fs.statSync(DOCS_PATH); const mtime = stat.mtimeMs; if (!cachedDocs || mtime > lastModified) { logger.info('docs_loader', 'Loading documentation from disk'); const content = fs.readFileSync(DOCS_PATH, "utf-8"); cachedDocs = JSON.parse(content); lastModified = mtime; logger.recordCacheMiss(); const endpointCount = Object.keys(cachedDocs.endpoints || {}).length; const pageCount = Object.keys(cachedDocs.pages || {}).length; logger.info('docs_loader', 'Documentation loaded', { endpoints: endpointCount, pages: pageCount }); } else { logger.recordCacheHit(); } return cachedDocs; } catch (err) { logger.error('docs_loader', 'Failed to load documentation', err); return { endpoints: {}, pages: {}, blogs: {}, authentication: {}, meta: {} }; } } /** * Get data freshness information * Returns object with age, status (fresh/warning/stale), and human-readable age */ function getDataFreshness() { try { const stat = fs.statSync(DOCS_PATH); const ageMs = Date.now() - stat.mtimeMs; const ageHours = ageMs / (60 * 60 * 1000); const ageDays = ageHours / 24; let status = 'fresh'; if (ageMs > DATA_FRESHNESS.STALE_THRESHOLD) { status = 'stale'; logger.warn('data_freshness', 'Documentation is STALE', { ageHours: ageHours.toFixed(1), threshold: DATA_FRESHNESS.STALE_THRESHOLD / (60 * 60 * 1000) }); } else if (ageMs > DATA_FRESHNESS.WARNING_THRESHOLD) { status = 'warning'; } return { lastModified: new Date(stat.mtimeMs).toISOString(), ageMs, ageHuman: ageDays >= 1 ? `${ageDays.toFixed(1)} days` : `${ageHours.toFixed(1)} hours`, status, thresholds: { warning: `${DATA_FRESHNESS.WARNING_THRESHOLD / (60 * 60 * 1000)}h`, stale: `${DATA_FRESHNESS.STALE_THRESHOLD / (60 * 60 * 1000)}h` } }; } catch (err) { logger.error('data_freshness', 'Failed to check data freshness', err); return { lastModified: null, ageMs: null, ageHuman: 'unknown', status: 'error', error: err.message }; } } // ========== SEARCH FUNCTIONS ========== /** * Advanced tokenizer with camelCase and compound word support * Examples: * "getUserInfo" → ["get", "user", "info"] * "get_user_info" → ["get", "user", "info"] * "OAuth2Token" → ["oauth", "2", "token"] */ function tokenize(text) { // Step 1: Split camelCase and PascalCase // "getUserInfo" → "get User Info" // "OAuth2Token" → "O Auth 2 Token" let processed = text.replace(/([a-z])([A-Z])/g, '$1 $2'); // Step 2: Split numbers from letters // "OAuth2Token" → "OAuth 2 Token" processed = processed.replace(/([a-zA-Z])(\d)/g, '$1 $2'); processed = processed.replace(/(\d)([a-zA-Z])/g, '$1 $2'); // Step 3: Replace separators with spaces processed = processed .toLowerCase() .replace(/[_\-\/\.]/g, ' ') .replace(/[^a-z0-9\s]/g, ''); // Step 4: Split and filter const tokens = processed .split(/\s+/) .filter(t => t.length > 1); // Step 5: Deduplicate while preserving order return [...new Set(tokens)]; } /** * Generates n-grams from tokens for fuzzy matching */ function generateNGrams(tokens, n = 2) { const ngrams = []; for (const token of tokens) { if (token.length >= n) { for (let i = 0; i <= token.length - n; i++) { ngrams.push(token.slice(i, i + n)); } } } return ngrams; } /** * Advanced scoring algorithm with weighted matching * Score breakdown: * - Exact token match: 1.0 * - Prefix match: 0.8 * - Substring match: 0.6 * - N-gram match: 0.3 * - Multiple token bonus: +0.5 per additional match */ function calculateScore(searchText, queryTokens) { const textLower = searchText.toLowerCase(); const textTokens = tokenize(searchText); const textNGrams = new Set(generateNGrams(textTokens)); let score = 0; let matchCount = 0; for (const token of queryTokens) { let tokenScore = 0; // Exact token match (highest weight) if (textTokens.includes(token)) { tokenScore = 10; matchCount++; } // Prefix match else if (textTokens.some(t => t.startsWith(token))) { tokenScore = 8; matchCount++; } // Suffix/substring match else if (textTokens.some(t => t.includes(token) || token.includes(t))) { tokenScore = 6; matchCount++; } // Direct text inclusion (handles compound words) else if (textLower.includes(token)) { tokenScore = 5; matchCount++; } // N-gram fuzzy match else { const queryNGrams = generateNGrams([token]); const ngramMatches = queryNGrams.filter(ng => textNGrams.has(ng)).length; if (ngramMatches > 0) { tokenScore = Math.min(3, ngramMatches * 0.5); } } score += tokenScore; } // Multi-token bonus: reward results that match multiple query terms if (matchCount > 1) { score += matchCount * 5; } // Position bonus: boost if match appears in first word (likely endpoint name) if (textTokens.length > 0 && queryTokens.some(t => textTokens[0].includes(t))) { score += 3; } return score; } function searchInDocs(query, maxResults = 20) { const data = loadDocs(); const queryTokens = tokenize(query); const results = []; // Search endpoints for (const [name, item] of Object.entries(data.endpoints || {})) { const searchText = [ name, item.title || "", item.description || "", item.method || "", item.path || "", item.curl_example || "", item.raw_text || "", (item.parameters || []).map(p => p.name + ' ' + p.description).join(' '), ].join(" "); const score = calculateScore(searchText, queryTokens); if (score > 0) { results.push({ type: "endpoint", name, title: item.title, description: item.description, method: item.method, path: item.path, url: item.url, score, }); } } // Search pages for (const [name, item] of Object.entries(data.pages || {})) { const searchText = [ name, item.title || "", item.description || "", item.raw_text || "", (item.paragraphs || []).join(" "), (item.list_items || []).join(" "), (item.headers || []).map(h => h.text).join(" "), ].join(" "); const score = calculateScore(searchText, queryTokens); if (score > 0) { results.push({ type: "page", name, title: item.title, description: item.description, url: item.url, category: item.category, score, }); } } // Search blogs for (const [name, item] of Object.entries(data.blogs || {})) { const searchText = [ name, item.title || "", item.description || "", item.raw_text || "", (item.paragraphs || []).join(" "), ].join(" "); const score = calculateScore(searchText, queryTokens); if (score > 0) { results.push({ type: "blog", name, title: item.title, description: item.description, url: item.url, score, }); } } return results.sort((a, b) => b.score - a.score).slice(0, maxResults); } // ========== MCP SERVER ========== const server = new Server( { name: "twitterapi-docs", version: "1.0.3", }, { capabilities: { tools: {}, resources: {}, completions: {}, }, } ); // ========== TOOL DEFINITIONS (LLM-OPTIMIZED) ========== server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "search_twitterapi_docs", description: `Search TwitterAPI.io documentation: API endpoints, guides (pricing, rate limits, filter rules), and blog posts. USE THIS WHEN: You need to find information across the entire documentation. RETURNS: Ranked results with endpoint paths, descriptions, and relevance scores. Examples: - "advanced search" → finds tweet search endpoints - "rate limit" → finds QPS limits and pricing info - "webhook" → finds webhook setup endpoints - "getUserInfo" → finds user info endpoints (supports camelCase)`, inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query (1-500 chars). Use English keywords like: 'search', 'user', 'tweet', 'webhook', 'pricing', 'rate limit'. Supports camelCase and underscore formats.", minLength: 1, maxLength: 500 }, max_results: { type: "integer", description: "Number of results to return. Use higher values (15-20) for comprehensive research, lower values (3-5) for quick lookups.", minimum: 1, maximum: 20, default: 10 } }, required: ["query"], }, outputSchema: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["text"] }, text: { type: "string", description: "Markdown formatted search results with sections: API Endpoints (name, method, path, description), Guides (name, title, url), Blog Posts (title, url)" } } } } } } }, { name: "get_twitterapi_endpoint", description: `Get complete documentation for a specific TwitterAPI.io endpoint. USE THIS WHEN: You know the exact endpoint name (e.g., from search results). RETURNS: Full details including path, parameters, cURL example, and code snippets. Common endpoints: - get_user_info, get_user_followers, get_user_following - tweet_advanced_search, get_tweet_by_id - add_webhook_rule, get_webhook_rules`, inputSchema: { type: "object", properties: { endpoint_name: { type: "string", description: "Exact endpoint name (use underscores). Examples: 'get_user_info', 'tweet_advanced_search', 'add_webhook_rule'", }, }, required: ["endpoint_name"], }, outputSchema: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["text"] }, text: { type: "string", description: "Markdown with: Title, Endpoint Details (method, path, full URL, doc link), Description, Parameters list (name, required, description), cURL Example, Code Examples, Full Documentation" } } } } } } }, { name: "list_twitterapi_endpoints", description: `List all TwitterAPI.io API endpoints organized by category. USE THIS WHEN: You need to browse available endpoints or find endpoints by category. CATEGORIES: user, tweet, community, webhook, stream, action, dm, list, trend RETURNS: Endpoint names with HTTP method and path for each category.`, inputSchema: { type: "object", properties: { category: { type: "string", description: "Optional filter: user, tweet, community, webhook, stream, action, dm, list, trend", enum: ["user", "tweet", "community", "webhook", "stream", "action", "dm", "list", "trend"] }, }, }, outputSchema: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["text"] }, text: { type: "string", description: "Markdown list organized by category (USER, TWEET, WEBHOOK, etc.) with endpoint format: name: METHOD /path" } } } } } } }, { name: "get_twitterapi_guide", description: `Get TwitterAPI.io guide pages for conceptual topics. USE THIS WHEN: You need information about pricing, rate limits, authentication, or filter rules. AVAILABLE GUIDES: pricing, qps_limits, tweet_filter_rules, changelog, introduction, authentication, readme RETURNS: Full guide content with headers, paragraphs, and code examples.`, inputSchema: { type: "object", properties: { guide_name: { type: "string", description: "Guide name: pricing, qps_limits, tweet_filter_rules, changelog, introduction, authentication, readme", enum: ["pricing", "qps_limits", "tweet_filter_rules", "changelog", "introduction", "authentication", "readme"] }, }, required: ["guide_name"], }, outputSchema: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["text"] }, text: { type: "string", description: "Markdown with: Title, URL, Overview, Table of Contents, Content paragraphs, Key Points list, Code Examples, Full Content" } } } } } } }, { name: "get_twitterapi_pricing", description: `Get TwitterAPI.io pricing information: credit system, endpoint costs, QPS limits. USE THIS WHEN: You need to know API costs, credit calculations, or rate limits. RETURNS: Pricing tiers, credit costs per endpoint, QPS limits by balance level.`, inputSchema: { type: "object", properties: {}, }, outputSchema: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["text"] }, text: { type: "string", description: "Markdown with: Credit System (USD to credits), Endpoint Costs table, Minimum Charge, QPS Limits by balance level, Important Notes, Cost Comparison" } } } } } } }, { name: "get_twitterapi_auth", description: `Get TwitterAPI.io authentication guide: API key usage, headers, code examples. USE THIS WHEN: You need to set up authentication or see request examples. RETURNS: API key header format, base URL, cURL/Python/JavaScript examples.`, inputSchema: { type: "object", properties: {}, }, outputSchema: { type: "object", properties: { content: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["text"] }, text: { type: "string", description: "Markdown with: API Key Usage header name, Base URL, Getting Your API Key steps, Request Examples (cURL, Python, JavaScript code blocks)" } } } } } } }, ], })); // ========== TOOL HANDLERS ========== server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const startTime = Date.now(); let success = true; try { const result = await handleToolCall(name, args); const duration = Date.now() - startTime; logger.recordToolCall(name, duration, !result.isError); logger.info('tool_call', `${name} completed`, { duration, isError: result.isError }); return result; } catch (error) { success = false; const duration = Date.now() - startTime; logger.recordToolCall(name, duration, false); logger.error('tool_call', `${name} failed`, error); return formatToolError({ type: ErrorType.INTERNAL_ERROR, message: 'An unexpected error occurred', suggestion: 'Try again or use a different query', retryable: true }); } }); async function handleToolCall(name, args) { const data = loadDocs(); switch (name) { case "search_twitterapi_docs": { // Validate input const validation = validateQuery(args.query); if (!validation.valid) { return formatToolError(validation.error); } // Validate and set max_results (default: 10, range: 1-20) const maxResults = Math.min(20, Math.max(1, args.max_results || 10)); // Check cache first (include maxResults in cache key) const cacheKey = `search_${validation.value}_${maxResults}`; const cachedOutput = searchCache.get(cacheKey); if (cachedOutput) { logger.info('search', 'Cache hit', { query: validation.value, maxResults }); return formatToolSuccess(cachedOutput + '\n\n*[Cached result]*'); } const results = searchInDocs(validation.value, maxResults); if (results.length === 0) { const allEndpoints = Object.keys(data.endpoints || {}).slice(0, 15); return formatToolSuccess(`No results for "${validation.value}". **Suggestions:** - Try different terms: "search", "user", "tweet", "webhook", "stream" - Use English keywords - Try broader terms **Available endpoints (sample):** ${allEndpoints.map(e => `- ${e}`).join('\n')} **Guide pages:** - pricing, qps_limits, tweet_filter_rules, changelog, authentication`); } const grouped = { endpoint: results.filter(r => r.type === "endpoint"), page: results.filter(r => r.type === "page"), blog: results.filter(r => r.type === "blog"), }; let output = `## "${validation.value}" - ${results.length} results (showing up to ${maxResults}):\n\n`; if (grouped.endpoint.length > 0) { output += `### API Endpoints (${grouped.endpoint.length})\n`; output += grouped.endpoint.slice(0, 15).map((r, i) => `${i + 1}. **${r.name}** - ${r.method || "GET"} ${r.path || ""}\n ${r.description || r.title || ""}` ).join("\n\n"); output += "\n\n"; } if (grouped.page.length > 0) { output += `### Guides (${grouped.page.length})\n`; output += grouped.page.slice(0, 10).map((r, i) => `${i + 1}. **${r.name}** - ${r.title || ""}\n ${r.url || ""}` ).join("\n\n"); output += "\n\n"; } if (grouped.blog.length > 0) { output += `### Blog Posts (${grouped.blog.length})\n`; output += grouped.blog.slice(0, 5).map((r, i) => `${i + 1}. **${r.title || r.name}**\n ${r.url || ""}` ).join("\n\n"); } // Cache the result searchCache.set(cacheKey, output); return formatToolSuccess(output); } case "get_twitterapi_endpoint": { // Validate input const validation = validateEndpointName(args.endpoint_name); if (!validation.valid) { return formatToolError(validation.error); } // Check cache first const cacheKey = `endpoint_${validation.value}`; const cachedOutput = endpointCache.get(cacheKey); if (cachedOutput) { logger.info('endpoint', 'Cache hit', { endpoint: validation.value }); return formatToolSuccess(cachedOutput); } const endpoint = data.endpoints?.[validation.value]; if (!endpoint) { const available = Object.keys(data.endpoints || {}); const suggestions = available .filter(e => e.includes(validation.value.split('_')[0]) || validation.value.includes(e.split('_')[0])) .slice(0, 10); return formatToolError({ type: ErrorType.NOT_FOUND, message: `Endpoint "${validation.value}" not found`, suggestion: suggestions.length > 0 ? `Similar endpoints: ${suggestions.join(', ')}` : `Use list_twitterapi_endpoints to see all ${available.length} available endpoints`, retryable: false }); } const info = `# ${endpoint.title || validation.value} ## Endpoint Details - **Method:** ${endpoint.method || "GET"} - **Path:** ${endpoint.path || "Unknown"} - **Full URL:** https://api.twitterapi.io${endpoint.path || ""} - **Documentation:** ${endpoint.url} ## Description ${endpoint.description || "No description available."} ${endpoint.parameters?.length > 0 ? `## Parameters ${endpoint.parameters.map(p => `- **${p.name}**${p.required ? ' (required)' : ''}: ${p.description}`).join('\n')}` : ''} ## cURL Example \`\`\`bash ${endpoint.curl_example || `curl --request ${endpoint.method || 'GET'} \\ --url https://api.twitterapi.io${endpoint.path || ''} \\ --header 'x-api-key: YOUR_API_KEY'`} \`\`\` ${endpoint.code_snippets?.length > 0 ? `## Code Examples \`\`\` ${endpoint.code_snippets.join("\n")} \`\`\`` : ""} ## Full Documentation ${endpoint.raw_text || "No additional content available."}`; // Cache the result endpointCache.set(cacheKey, info); return formatToolSuccess(info); } case "list_twitterapi_endpoints": { // Validate category (optional) const validation = validateCategory(args.category); if (!validation.valid) { return formatToolError(validation.error); } const endpoints = Object.entries(data.endpoints || {}); const categories = { user: [], tweet: [], list: [], community: [], trend: [], dm: [], action: [], webhook: [], stream: [], other: [], }; for (const [name, ep] of endpoints) { if (name.includes("user") || name.includes("follow")) { categories.user.push({ name, ...ep }); } else if (name.includes("tweet") || name.includes("search") || name.includes("article")) { categories.tweet.push({ name, ...ep }); } else if (name.includes("list")) { categories.list.push({ name, ...ep }); } else if (name.includes("community")) { categories.community.push({ name, ...ep }); } else if (name.includes("trend")) { categories.trend.push({ name, ...ep }); } else if (name.includes("dm")) { categories.dm.push({ name, ...ep }); } else if (name.includes("webhook") || name.includes("rule")) { categories.webhook.push({ name, ...ep }); } else if (name.includes("monitor") || name.includes("stream")) { categories.stream.push({ name, ...ep }); } else if (["login", "like", "retweet", "create", "delete", "upload"].some(k => name.includes(k))) { categories.action.push({ name, ...ep }); } else { categories.other.push({ name, ...ep }); } } if (validation.value && categories[validation.value]) { const filtered = categories[validation.value]; return formatToolSuccess(`## ${validation.value.toUpperCase()} Endpoints (${filtered.length}) ${filtered.map((e) => `- **${e.name}**: ${e.method || "GET"} ${e.path || ""}\n ${e.description || ""}`).join("\n\n")}`); } let output = `# TwitterAPI.io Endpoints (Total: ${endpoints.length})\n\n`; for (const [cat, eps] of Object.entries(categories)) { if (eps.length > 0) { output += `## ${cat.toUpperCase()} (${eps.length})\n`; output += eps.map((e) => `- **${e.name}**: ${e.method || "GET"} ${e.path || ""}`).join("\n"); output += "\n\n"; } } return formatToolSuccess(output); } case "get_twitterapi_guide": { // Validate input const validation = validateGuideName(args.guide_name); if (!validation.valid) { return formatToolError(validation.error); } const page = data.pages?.[validation.value]; if (!page) { return formatToolError({ type: ErrorType.NOT_FOUND, message: `Guide "${validation.value}" not found`, suggestion: `Available guides: ${Object.keys(data.pages || {}).join(', ')}`, retryable: false }); } let output = `# ${page.title || validation.value}\n\n`; output += `**URL:** ${page.url || "N/A"}\n\n`; if (page.description) { output += `## Overview\n${page.description}\n\n`; } if (page.headers?.length > 0) { output += `## Table of Contents\n`; output += page.headers.map(h => `${" ".repeat(h.level - 1)}- ${h.text}`).join("\n"); output += "\n\n"; } if (page.paragraphs?.length > 0) { output += `## Content\n`; output += page.paragraphs.join("\n\n"); output += "\n\n"; } if (page.list_items?.length > 0) { output += `## Key Points\n`; output += page.list_items.map(li => `- ${li}`).join("\n"); output += "\n\n"; } if (page.code_snippets?.length > 0) { output += `## Code Examples\n\`\`\`\n`; output += page.code_snippets.join("\n"); output += "\n```\n\n"; } output += `## Full Content\n${page.raw_text || "No additional content."}`; return formatToolSuccess(output); } case "get_twitterapi_pricing": { const pricing = data.pricing || {}; const qps = data.qps_limits || {}; return formatToolSuccess(`# TwitterAPI.io Pricing ## Credit System - **1 USD = ${pricing.credits_per_usd?.toLocaleString() || "100,000"} Credits** ## Endpoint Costs ${Object.entries(pricing.costs || {}).map(([k, v]) => `- **${k}**: ${v}`).join("\n")} ## Minimum Charge ${pricing.minimum_charge || "15 credits ($0.00015) per request"} ## QPS (Queries Per Second) Limits ### Free Users ${qps.free || "1 request per 5 seconds"} ### By Balance Level ${Object.entries(qps.paid || {}).map(([k, v]) => `- **${k.replace(/_/g, " ")}**: ${v}`).join("\n")} ## Important Notes - Credits never expire - Bonus credits valid for 30 days - Up to 5% discount on bulk purchases - List endpoints: 150 credits/request ## Cost Comparison TwitterAPI.io is **~97% cheaper** than official Twitter API. - Twitter Pro: $5,000/month - TwitterAPI.io equivalent: ~$150/month`); } case "get_twitterapi_auth": { const auth = data.authentication || {}; return formatToolSuccess(`# TwitterAPI.io Authentication ## API Key Usage All requests require the \`${auth.header || "x-api-key"}\` header. ## Base URL \`${auth.base_url || "https://api.twitterapi.io"}\` ## Getting Your API Key 1. Go to ${auth.dashboard_url || "https://twitterapi.io/dashboard"} 2. Sign up / Log in 3. Copy your API key from the dashboard ## Request Examples ### cURL \`\`\`bash curl -X GET "${auth.base_url || "https://api.twitterapi.io"}/twitter/user/info?userName=elonmusk" \\ -H "${auth.header || "x-api-key"}: YOUR_API_KEY" \`\`\` ### Python \`\`\`python import requests response = requests.get( "${auth.base_url || "https://api.twitterapi.io"}/twitter/user/info", params={"userName": "elonmusk"}, headers={"${auth.header || "x-api-key"}": "YOUR_API_KEY"} ) print(response.json()) \`\`\` ### JavaScript \`\`\`javascript const response = await fetch( "${auth.base_url || "https://api.twitterapi.io"}/twitter/user/info?userName=elonmusk", { headers: { "${auth.header || "x-api-key"}": "YOUR_API_KEY" } } ); const data = await response.json(); \`\`\``); } default: return formatToolError({ type: ErrorType.NOT_FOUND, message: `Unknown tool: ${name}`, suggestion: 'Available tools: search_twitterapi_docs, get_twitterapi_endpoint, list_twitterapi_endpoints, get_twitterapi_guide, get_twitterapi_pricing, get_twitterapi_auth', retryable: false }); } } // ========== RESOURCES ========== server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ // Documentation resources { uri: "twitterapi://docs/all", mimeType: "application/json", name: "All TwitterAPI.io Documentation", description: "52 endpoints + guide pages + blog posts", }, { uri: "twitterapi://docs/endpoints", mimeType: "application/json", name: "API Endpoint List", description: "Summary of all API endpoints", }, { uri: "twitterapi://docs/guides", mimeType: "application/json", name: "Guide Pages", description: "Pricing, QPS limits, filter rules, etc.", }, // Static guide resources (Phase 2) { uri: "twitterapi://guides/pricing", mimeType: "text/markdown", name: "Pricing Guide", description: "Credit system, endpoint costs, QPS limits", }, { uri: "twitterapi://guides/authentication", mimeType: "text/markdown", name: "Authentication Guide", description: "API key setup, headers, code examples", }, { uri: "twitterapi://guides/qps_limits", mimeType: "text/markdown", name: "Rate Limits Guide", description: "QPS limits by balance level", }, { uri: "twitterapi://guides/tweet_filter_rules", mimeType: "text/markdown", name: "Tweet Filter Rules", description: "Advanced search filter syntax", }, // Monitoring resources { uri: "twitterapi://metrics", mimeType: "application/json", name: "Server Metrics", description: "Performance metrics, SLO tracking, cache stats", }, { uri: "twitterapi://health", mimeType: "application/json", name: "Health Check", description: "Server health status and data freshness", }, ], })); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; const data = loadDocs(); // Documentation resources if (uri === "twitterapi://docs/all") { return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(data, null, 2), }], }; } if (uri === "twitterapi://docs/endpoints") { const summary = Object.entries(data.endpoints || {}).map(([name, ep]) => ({ name, method: ep.method, path: ep.path, description: ep.description, })); return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(summary, null, 2), }], }; } if (uri === "twitterapi://docs/guides") { return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify({ pages: data.pages, blogs: data.blogs }, null, 2), }], }; } // Static guide resources (Phase 2 - pre-rendered markdown for quick access) if (uri === "twitterapi://guides/pricing") { const pricing = data.pricing || {}; const qps = data.qps_limits || {}; return { contents: [{ uri, mimeType: "text/markdown", text: `# TwitterAPI.io Pricing ## Credit System - **1 USD = ${pricing.credits_per_usd?.toLocaleString() || "100,000"} Credits** ## Endpoint Costs ${Object.entries(pricing.costs || {}).map(([k, v]) => `- **${k}**: ${v}`).join("\n")} ## QPS Limits by Balance Level ${Object.entries(qps.paid || {}).map(([k, v]) => `- **${k.replace(/_/g, " ")}**: ${v}`).join("\n")} ## Important Notes - Credits never expire - Bonus credits valid for 30 days - ~97% cheaper than official Twitter API`, }], }; } if (uri === "twitterapi://guides/authentication") { const auth = data.authentication || {}; return { contents: [{ uri, mimeType: "text/markdown", text: `# TwitterAPI.io Authentication ## API Key Header \`${auth.header || "x-api-key"}: YOUR_API_KEY\` ## Base URL \`${auth.base_url || "https://api.twitterapi.io"}\` ## Quick Example \`\`\`bash curl -X GET "${auth.base_url || "https://api.twitterapi.io"}/twitter/user/info?userName=elonmusk" \\ -H "${auth.header || "x-api-key"}: YOUR_API_KEY" \`\`\``, }], }; } if (uri === "twitterapi://guides/qps_limits") { const qps = data.qps_limits || {}; return { contents: [{ uri, mimeType: "text/markdown", text: `# TwitterAPI.io Rate Limits (QPS) ## Free Users ${qps.free || "1 request per 5 seconds"} ## Paid Users by Balance ${Object.entries(qps.paid || {}).map(([k, v]) => `- **${k.replace(/_/g, " ")}**: ${v}`).join("\n")}`, }], }; } if (uri === "twitterapi://guides/tweet_filter_rules") { const page = data.pages?.tweet_filter_rules || {}; return { contents: [{ uri, mimeType: "text/markdown", text: `# Tweet Filter Rules ${page.raw_text || page.description || "Filter rules documentation not available."}`, }], }; } // Monitoring resources if (uri === "twitterapi://metrics") { return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(logger.getMetrics(getAllCacheStats(), getDataFreshness()), null, 2), }], }; } if (uri === "twitterapi://health") { const freshness = getDataFreshness(); const health = { status: freshness.status === 'stale' ? 'degraded' : 'healthy', timestamp: new Date().toISOString(), uptime: process.uptime(), dataFreshness: freshness, cache: { search: searchCache.stats(), endpoints: endpointCache.stats() }, sloStatus: { violations: logger.metrics.sloViolations, healthy: logger.metrics.sloViolations.alert === 0 } }; return { contents: [{ uri, mimeType: "application/json", text: JSON.stringify(health, null, 2), }], }; } throw new Error(`Unknown resource: ${uri}`); }); // ========== COMPLETIONS HANDLER (for Glama.ai compatibility) ========== server.setRequestHandler(CompleteRequestSchema, async () => { // Return empty completions - we don't provide autocomplete suggestions // but declaring the capability allows mcp-proxy to work correctly return { completion: { values: [], hasMore: false, total: 0 } }; }); // ========== SERVER STARTUP ========== async function main() { try { logger.info('init', 'Starting TwitterAPI.io Docs MCP Server v1.0.3'); // Validate docs file exists if (!fs.existsSync(DOCS_PATH)) { throw new Error(`Documentation file not found: ${DOCS_PATH}`); } // Pre-load documentation const docs = loadDocs(); const endpointCount = Object.keys(docs.endpoints || {}).length; const pageCount = Object.keys(docs.pages || {}).length; logger.info('init', 'Documentation validated', { endpoints: endpointCount, pages: pageCount }); // Check data freshness const freshness = getDataFreshness(); logger.info('init', 'Data freshness check', freshness); if (freshness.status === 'stale') { logger.warn('init', 'WARNING: Documentation data is stale! Consider refreshing.'); } // Start cache cleanup scheduler startCacheCleanup(); logger.info('init', 'Cache cleanup scheduler started (hourly)'); // Log SLO configuration logger.info('init', 'SLO targets configured', { tools: Object.keys(SLO).length, targets: Object.entries(SLO).map(([t, s]) => `${t}: ${s.target}ms`) }); // Connect transport const transport = new StdioServerTransport(); await server.connect(transport); logger.info('init', 'MCP Server ready on stdio', { version: '1.0.3', features: ['max_results', 'camelCase', 'SLO tracking', 'MCP Resources', 'data freshness', 'trusted publishing'] }); // Graceful shutdown process.on('SIGINT', () => { logger.info('shutdown', 'Received SIGINT, shutting down...'); stopCacheCleanup(); logger.info('shutdown', 'Final metrics', logger.getMetrics(getAllCacheStats(), getDataFreshness())); process.exit(0); }); process.on('SIGTERM', () => { logger.info('shutdown', 'Received SIGTERM, shutting down...'); stopCacheCleanup(); logger.info('shutdown', 'Final metrics', logger.getMetrics(getAllCacheStats(), getDataFreshness())); process.exit(0); }); } catch (error) { logger.error('init', 'Fatal error during initialization', error); process.exit(1); } } main().catch((error) => { console.error('[FATAL] Unexpected error:', error); process.exit(1); });

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/dorukardahan/twitterapi-docs-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server