Skip to main content
Glama
index.test.js22.4 kB
/** * Unit tests for TwitterAPI.io MCP Server * Tests: Input validation, Search functions, Cache operations */ import assert from 'node:assert'; import { describe, it, before } from 'node:test'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOCS_PATH = path.join(__dirname, '..', 'data', 'docs.json'); // ========== VALIDATION FUNCTIONS (copied for testing) ========== 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'] }; const ErrorType = { INPUT_VALIDATION: 'input_validation', NOT_FOUND: 'not_found', INTERNAL_ERROR: 'internal_error', TIMEOUT: 'timeout', }; 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 }; } 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 }; } // ========== SEARCH FUNCTIONS (copied for testing - Phase 2 version) ========== /** * Advanced tokenizer with camelCase and compound word support */ function tokenize(text) { // Step 1: Split camelCase and PascalCase let processed = text.replace(/([a-z])([A-Z])/g, '$1 $2'); // Step 2: Split numbers from letters 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 */ 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 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 if (matchCount > 1) { score += matchCount * 5; } // Position bonus if (textTokens.length > 0 && queryTokens.some(t => textTokens[0].includes(t))) { score += 3; } return score; } // ========== TESTS ========== describe('Input Validation', () => { describe('validateQuery', () => { it('should accept valid queries', () => { const result = validateQuery('user info'); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, 'user info'); }); it('should reject empty queries', () => { const result = validateQuery(''); assert.strictEqual(result.valid, false); assert.strictEqual(result.error.type, ErrorType.INPUT_VALIDATION); }); it('should reject null queries', () => { const result = validateQuery(null); assert.strictEqual(result.valid, false); }); it('should trim whitespace', () => { const result = validateQuery(' webhook '); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, 'webhook'); }); it('should reject queries exceeding max length', () => { const longQuery = 'a'.repeat(501); const result = validateQuery(longQuery); assert.strictEqual(result.valid, false); assert.ok(result.error.message.includes('too long')); }); it('should accept queries at max length', () => { const maxQuery = 'a'.repeat(500); const result = validateQuery(maxQuery); assert.strictEqual(result.valid, true); }); }); describe('validateEndpointName', () => { it('should accept valid endpoint names', () => { const result = validateEndpointName('get_user_info'); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, 'get_user_info'); }); it('should accept names with hyphens', () => { const result = validateEndpointName('get-user-info'); assert.strictEqual(result.valid, true); }); it('should reject empty names', () => { const result = validateEndpointName(''); assert.strictEqual(result.valid, false); }); it('should reject names with special characters', () => { const result = validateEndpointName('get/user/info'); assert.strictEqual(result.valid, false); }); it('should reject names with spaces', () => { const result = validateEndpointName('get user info'); assert.strictEqual(result.valid, false); }); }); describe('validateGuideName', () => { it('should accept valid guide names', () => { const result = validateGuideName('pricing'); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, 'pricing'); }); it('should be case-insensitive', () => { const result = validateGuideName('PRICING'); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, 'pricing'); }); it('should reject unknown guide names', () => { const result = validateGuideName('unknown_guide'); assert.strictEqual(result.valid, false); assert.ok(result.error.message.includes('Unknown guide')); }); it('should reject empty names', () => { const result = validateGuideName(''); assert.strictEqual(result.valid, false); }); it('should accept all valid guide names', () => { for (const guide of VALIDATION.GUIDE_NAMES) { const result = validateGuideName(guide); assert.strictEqual(result.valid, true, `Failed for guide: ${guide}`); } }); }); describe('validateCategory', () => { it('should accept valid categories', () => { const result = validateCategory('user'); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, 'user'); }); it('should accept null (optional parameter)', () => { const result = validateCategory(null); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, null); }); it('should accept undefined (optional parameter)', () => { const result = validateCategory(undefined); assert.strictEqual(result.valid, true); }); it('should reject unknown categories', () => { const result = validateCategory('invalid_category'); assert.strictEqual(result.valid, false); }); it('should be case-insensitive', () => { const result = validateCategory('USER'); assert.strictEqual(result.valid, true); assert.strictEqual(result.value, 'user'); }); }); }); describe('Search Functions', () => { describe('tokenize', () => { it('should split by spaces', () => { const tokens = tokenize('hello world'); assert.deepStrictEqual(tokens, ['hello', 'world']); }); it('should handle underscores', () => { const tokens = tokenize('get_user_info'); assert.deepStrictEqual(tokens, ['get', 'user', 'info']); }); it('should handle hyphens', () => { const tokens = tokenize('get-user-info'); assert.deepStrictEqual(tokens, ['get', 'user', 'info']); }); // Phase 2: camelCase support it('should split camelCase into separate tokens', () => { const tokens = tokenize('getUserInfo'); assert.deepStrictEqual(tokens, ['get', 'user', 'info']); }); it('should split PascalCase into separate tokens', () => { const tokens = tokenize('UserFollowers'); assert.deepStrictEqual(tokens, ['user', 'followers']); }); it('should split numbers from letters', () => { const tokens = tokenize('OAuth2Token'); assert.ok(tokens.includes('oauth')); assert.ok(tokens.includes('token')); }); it('should filter short tokens', () => { const tokens = tokenize('a b cd ef'); assert.deepStrictEqual(tokens, ['cd', 'ef']); }); it('should remove special characters', () => { const tokens = tokenize('hello! world?'); assert.deepStrictEqual(tokens, ['hello', 'world']); }); it('should deduplicate tokens', () => { const tokens = tokenize('user user_info userInfo'); // Should not have duplicate 'user' tokens const userCount = tokens.filter(t => t === 'user').length; assert.strictEqual(userCount, 1); }); }); describe('generateNGrams', () => { it('should generate bigrams from tokens', () => { const ngrams = generateNGrams(['hello']); assert.deepStrictEqual(ngrams, ['he', 'el', 'll', 'lo']); }); it('should handle multiple tokens', () => { const ngrams = generateNGrams(['ab', 'cd']); assert.deepStrictEqual(ngrams, ['ab', 'cd']); }); it('should skip tokens shorter than n', () => { const ngrams = generateNGrams(['a', 'bc', 'def'], 2); assert.ok(!ngrams.includes('a')); assert.ok(ngrams.includes('bc')); }); it('should support custom n value', () => { const ngrams = generateNGrams(['hello'], 3); assert.deepStrictEqual(ngrams, ['hel', 'ell', 'llo']); }); }); describe('calculateScore', () => { it('should score exact matches higher', () => { const score1 = calculateScore('user info endpoint', ['user']); const score2 = calculateScore('something else', ['user']); assert.ok(score1 > score2); }); it('should score multiple matches higher', () => { const score1 = calculateScore('user info api', ['user', 'info']); const score2 = calculateScore('user data', ['user', 'info']); assert.ok(score1 > score2); }); it('should return 0 for no matches', () => { const score = calculateScore('hello world', ['xyz', 'abc']); assert.strictEqual(score, 0); }); it('should handle partial matches', () => { const score = calculateScore('get_user_followers', ['user']); assert.ok(score > 0); }); }); }); describe('Documentation Data', () => { let docs; before(() => { docs = JSON.parse(fs.readFileSync(DOCS_PATH, 'utf-8')); }); it('should have endpoints', () => { assert.ok(docs.endpoints); assert.ok(Object.keys(docs.endpoints).length > 0); }); it('should have pages', () => { assert.ok(docs.pages); assert.ok(Object.keys(docs.pages).length > 0); }); it('should have pricing page', () => { assert.ok(docs.pages.pricing); }); it('should have authentication page', () => { assert.ok(docs.pages.authentication); }); it('should have legal pages', () => { assert.ok(docs.pages.terms, 'Terms page missing'); assert.ok(docs.pages.acceptable_use, 'Acceptable Use page missing'); }); it('should have dashboard page', () => { assert.ok(docs.pages.dashboard, 'Dashboard page missing'); }); it('endpoints should have required fields', () => { for (const [name, endpoint] of Object.entries(docs.endpoints)) { assert.ok(endpoint.url, `Endpoint ${name} missing url`); // Method and path are optional but common } }); it('pages should have required fields', () => { for (const [name, page] of Object.entries(docs.pages)) { assert.ok(page.url || page.title, `Page ${name} missing url or title`); } }); }); describe('Cache Key Normalization', () => { function normalizeKey(key) { return key.toLowerCase().replace(/[^a-z0-9]/g, '_').slice(0, 100); } it('should lowercase keys', () => { assert.strictEqual(normalizeKey('HELLO'), 'hello'); }); it('should replace special chars with underscore', () => { assert.strictEqual(normalizeKey('hello world'), 'hello_world'); assert.strictEqual(normalizeKey('hello-world'), 'hello_world'); }); it('should limit key length to 100', () => { const longKey = 'a'.repeat(150); assert.strictEqual(normalizeKey(longKey).length, 100); }); it('should handle search queries', () => { const key = normalizeKey('search_user info webhook'); assert.strictEqual(key, 'search_user_info_webhook'); }); }); describe('MCP Protocol Integration', () => { it('should return structuredContent for tools with outputSchema', async () => { const repoRoot = path.join(__dirname, '..'); const transport = new StdioClientTransport({ command: process.execPath, args: [path.join(repoRoot, 'index.js')], cwd: repoRoot, stderr: 'pipe', }); const client = new Client({ name: 'twitterapi-docs-mcp-tests', version: '0.0.0' }); try { await client.connect(transport); const tools = await client.listTools(); assert.ok(tools.tools.some(t => t.name === 'get_twitterapi_pricing')); const pricing = await client.callTool({ name: 'get_twitterapi_pricing', arguments: {} }); assert.ok(pricing.structuredContent, 'Expected structuredContent in tool result'); assert.strictEqual(typeof pricing.structuredContent.markdown, 'string'); const page = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'https://twitterapi.io/pricing' } }); assert.ok(page.structuredContent, 'Expected structuredContent in URL tool result'); assert.strictEqual(page.structuredContent.kind, 'page'); assert.strictEqual(page.structuredContent.name, 'pricing'); assert.strictEqual(typeof page.structuredContent.markdown, 'string'); const httpUpgrade = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'http://twitterapi.io/pricing' } }); assert.ok(httpUpgrade.structuredContent, 'Expected structuredContent for http→https upgrade'); assert.strictEqual(httpUpgrade.structuredContent.kind, 'page'); assert.strictEqual(httpUpgrade.structuredContent.name, 'pricing'); const wwwAlias = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'https://www.twitterapi.io/pricing' } }); assert.ok(wwwAlias.structuredContent, 'Expected structuredContent for www host alias'); assert.strictEqual(wwwAlias.structuredContent.kind, 'page'); assert.strictEqual(wwwAlias.structuredContent.name, 'pricing'); const keyLookup = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'qps_limits' } }); assert.ok(keyLookup.structuredContent, 'Expected structuredContent for key-based lookup'); assert.strictEqual(keyLookup.structuredContent.kind, 'page'); assert.strictEqual(keyLookup.structuredContent.name, 'qps_limits'); const blogKey = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'blog_pricing_2025' } }); assert.ok(blogKey.structuredContent, 'Expected structuredContent for blog key lookup'); assert.strictEqual(blogKey.structuredContent.kind, 'blog'); assert.strictEqual(blogKey.structuredContent.name, 'blog_pricing_2025'); const docsRoot = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'https://docs.twitterapi.io/' } }); assert.ok(docsRoot.structuredContent, 'Expected structuredContent for docs root alias'); assert.strictEqual(docsRoot.structuredContent.kind, 'page'); assert.strictEqual(docsRoot.structuredContent.name, 'introduction'); const apiRef = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'https://docs.twitterapi.io/api-reference' } }); assert.ok(apiRef.structuredContent, 'Expected structuredContent for api-reference alias'); assert.strictEqual(apiRef.structuredContent.kind, 'page'); assert.strictEqual(apiRef.structuredContent.name, 'docs_api_reference'); const documentationAlias = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: '/documentation/authentication' } }); assert.ok(documentationAlias.structuredContent, 'Expected structuredContent for /documentation/* alias'); assert.strictEqual(documentationAlias.structuredContent.source, 'snapshot'); assert.strictEqual(documentationAlias.structuredContent.kind, 'page'); assert.strictEqual(documentationAlias.structuredContent.name, 'authentication'); const endpointsResource = await client.readResource({ uri: 'twitterapi://docs/endpoints' }); const endpointsText = endpointsResource.contents?.[0]?.text ?? '[]'; const endpointsJson = JSON.parse(endpointsText); assert.ok(Array.isArray(endpointsJson) && endpointsJson.length > 0); assert.ok(endpointsJson[0].method, 'Expected method in endpoint resource summary'); const streamEndpoints = await client.callTool({ name: 'list_twitterapi_endpoints', arguments: { category: 'stream' } }); assert.ok(streamEndpoints.structuredContent, 'Expected structuredContent for list_twitterapi_endpoints(stream)'); assert.ok(Array.isArray(streamEndpoints.structuredContent.endpoints)); assert.ok( streamEndpoints.structuredContent.endpoints.some((e) => e.name === 'add_user_to_monitor_tweet'), 'Expected add_user_to_monitor_tweet to be categorized as stream' ); const dmEndpoints = await client.callTool({ name: 'list_twitterapi_endpoints', arguments: { category: 'dm' } }); assert.ok(dmEndpoints.structuredContent, 'Expected structuredContent for list_twitterapi_endpoints(dm)'); assert.ok(Array.isArray(dmEndpoints.structuredContent.endpoints)); assert.ok( dmEndpoints.structuredContent.endpoints.some((e) => e.name === 'send_dm_v2'), 'Expected send_dm_v2 to be categorized as dm' ); const badHost = await client.callTool({ name: 'get_twitterapi_url', arguments: { url: 'https://example.com/' } }); assert.strictEqual(badHost.isError, true); } finally { await client.close(); } }); }); // Run tests console.log('Running TwitterAPI.io MCP Server tests...\n');

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-io-mcp'

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